oficina-opencode 3.1.0 → 3.1.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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/commands/oficina.md +7 -6
  3. package/dashboard/.dom-check.html +1257 -0
  4. package/dashboard/README-DASHBOARD.md +64 -0
  5. package/dashboard/assets/factory.css +1543 -0
  6. package/dashboard/assets/tokens.css +208 -0
  7. package/dashboard/build-kanban-data.mjs +279 -0
  8. package/dashboard/dashboard.css +753 -0
  9. package/dashboard/dashboard.html +121 -0
  10. package/dashboard/dashboard.js +610 -0
  11. package/dashboard/data/snapshot.json +883 -0
  12. package/dashboard/debate.html +134 -0
  13. package/dashboard/factory/build-snapshot.mjs +89 -0
  14. package/dashboard/factory/factory-app.js +1185 -0
  15. package/dashboard/factory/factory-core.js +541 -0
  16. package/dashboard/factory/snapshot.js +850 -0
  17. package/dashboard/factory.html +350 -0
  18. package/dashboard/kanban-data.json +671 -0
  19. package/dashboard/kanban-source.js +643 -0
  20. package/dashboard/kanban.check.mjs +454 -0
  21. package/dashboard/kanban.html +1257 -0
  22. package/dashboard/leadership.html +1008 -0
  23. package/dashboard/lib/debate-app.js +1149 -0
  24. package/dashboard/lib/debate-core.js +698 -0
  25. package/dashboard/lib/debate-data.js +163 -0
  26. package/dashboard/lib/debate-demo.js +110 -0
  27. package/dashboard/lib/debate.css +1155 -0
  28. package/dashboard/lib/leadership-model.js +946 -0
  29. package/dashboard/probe.mjs +35 -0
  30. package/dashboard/scripts/serve.mjs +122 -0
  31. package/dashboard/scripts/snapshot-data.mjs +93 -0
  32. package/dashboard/scripts/snapshot.mjs +28 -0
  33. package/dashboard/server.mjs +242 -0
  34. package/dashboard/shot.mjs +54 -0
  35. package/dashboard/shots/desktop-gates.png +0 -0
  36. package/dashboard/shots/desktop-light.png +0 -0
  37. package/dashboard/shots/desktop-real.png +0 -0
  38. package/dashboard/shots/mobile.png +0 -0
  39. package/dashboard/test/factory-core.test.mjs +244 -0
  40. package/dashboard/tests/debate-core.test.mjs +336 -0
  41. package/dashboard/tests/leadership-model.test.mjs +404 -0
  42. package/dist/cli.js +24 -0
  43. package/dist/cli.js.map +1 -1
  44. package/dist/core/dashboard.d.ts +33 -0
  45. package/dist/core/dashboard.d.ts.map +1 -0
  46. package/dist/core/dashboard.js +115 -0
  47. package/dist/core/dashboard.js.map +1 -0
  48. package/dist/core/dispatch.d.ts.map +1 -1
  49. package/dist/core/dispatch.js +11 -0
  50. package/dist/core/dispatch.js.map +1 -1
  51. package/dist/core/environment.d.ts +28 -0
  52. package/dist/core/environment.d.ts.map +1 -0
  53. package/dist/core/environment.js +80 -0
  54. package/dist/core/environment.js.map +1 -0
  55. package/dist/core/systemPrompt.d.ts.map +1 -1
  56. package/dist/core/systemPrompt.js +3 -0
  57. package/dist/core/systemPrompt.js.map +1 -1
  58. package/dist/tools/index.d.ts +9 -1
  59. package/dist/tools/index.d.ts.map +1 -1
  60. package/dist/tools/index.js +26 -22
  61. package/dist/tools/index.js.bak +972 -0
  62. package/dist/tools/index.js.map +1 -1
  63. package/dist/v2.d.ts +5 -0
  64. package/dist/v2.d.ts.map +1 -1
  65. package/dist/v2.js +57 -5
  66. package/dist/v2.js.bak +517 -0
  67. package/dist/v2.js.map +1 -1
  68. package/docs/QUE-ES.md +1 -1
  69. package/package.json +4 -1
@@ -0,0 +1,610 @@
1
+ /* ============================================================
2
+ Oficina · Dashboard visual — lógica
3
+ Fuente de datos: .oficina/ (state.json, roster.json, bus/, artifacts/)
4
+ Vía server.mjs (SSE + /api/state) o modo demo si no hay server.
5
+ ASCII-only: los símbolos se escriben con escapes \uXXXX.
6
+ ============================================================ */
7
+
8
+ const JEFES = new Set(["oficina-director", "oficina-integrator", "oficina-architect", "oficina-commander", "oficina-guardian"]);
9
+ const COLS = ["todo", "in_progress", "review", "done", "rework", "blocked", "escalated"];
10
+ const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
11
+
12
+ const SYM = {
13
+ arrow: "\u2192", // →
14
+ middot: "\u00B7", // ·
15
+ check: "\u2713", // ✓
16
+ cross: "\u2717", // ✕
17
+ diamond: "\u25C8", // ◈
18
+ rework: "\u21BB", // ↻
19
+ chief: "\u2318", // ⌘
20
+ user: "\u25CB", // ○
21
+ lens: "\u25CE", // ◎
22
+ play: '<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 1l7 4-7 4z" fill="currentColor"/></svg>',
23
+ pause: '<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 1h2.5v8H2zM6 1h2.5v8H6z" fill="currentColor"/></svg>',
24
+ };
25
+
26
+ /* ---------------- Estado ---------------- */
27
+ const state = {
28
+ runId: null,
29
+ cards: {},
30
+ agents: {},
31
+ bus: [],
32
+ replaying: false,
33
+ };
34
+ const snapshots = [];
35
+
36
+ const $ = (sel) => document.querySelector(sel);
37
+ const el = {
38
+ runId: $("#run-id"), conn: $("#conn"), connLabel: $("#conn-label"),
39
+ demoBanner: $("#demo-banner"),
40
+ mCards: $("#m-cards"), mActive: $("#m-active"), mFree: $("#m-free"),
41
+ mMsgs: $("#m-msgs"), mBlocked: $("#m-blocked"), mDone: $("#m-done"),
42
+ mesaSvg: $("#mesa-svg"), mesaCount: $("#mesa-count"),
43
+ busStream: $("#bus-stream"), busCount: $("#bus-count"),
44
+ chiefFeed: $("#chief-feed"), chiefCount: $("#chief-count"),
45
+ board: $("#board"), toasts: $("#toasts"),
46
+ replay: $("#replay"), replayRange: $("#replay-range"), replayClock: $("#replay-clock"),
47
+ btnPlay: $("#btn-play"), btnReplay: $("#btn-replay"), btnTheme: $("#btn-theme"),
48
+ gates: $("#gates"), linkEvidence: $("#link-evidence"), linkHandoff: $("#link-handoff"),
49
+ };
50
+
51
+ /* ---------------- Utilidades ---------------- */
52
+ const fmtTime = (iso) => {
53
+ if (!iso) return "";
54
+ // locale del navegador (guideline i18n): no forzar "es"
55
+ return new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
56
+ };
57
+ const shortName = (id) => {
58
+ if (!id) return "";
59
+ const a = state.agents[id];
60
+ if (a && a.displayName) return a.displayName;
61
+ if (id.startsWith("oficina-")) return SYM.chief + " " + id.replace(/^oficina-/, "");
62
+ return id;
63
+ };
64
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
65
+
66
+ /* ---------------- Toasts ---------------- */
67
+ function toast(kind, text, ms = 5000) {
68
+ const t = document.createElement("div");
69
+ t.className = "toast";
70
+ t.dataset.kind = kind;
71
+ t.setAttribute("role", "status");
72
+ const icon = kind === "spawn" ? SYM.diamond : kind === "gate-fail" ? SYM.cross : SYM.check;
73
+ t.innerHTML = `<span class="icon" aria-hidden="true">${icon}</span><span>${esc(text)}</span>`;
74
+ el.toasts.appendChild(t);
75
+ setTimeout(() => { t.classList.add("leaving"); setTimeout(() => t.remove(), 400); }, ms);
76
+ }
77
+
78
+ /* ---------------- Tema ---------------- */
79
+ const savedTheme = localStorage.getItem("oficina-theme") || "dark";
80
+ document.documentElement.dataset.theme = savedTheme;
81
+ el.btnTheme.addEventListener("click", () => {
82
+ const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
83
+ document.documentElement.dataset.theme = next;
84
+ localStorage.setItem("oficina-theme", next);
85
+ });
86
+
87
+ /* ============================================================
88
+ RENDER: mesa (SVG)
89
+ ============================================================ */
90
+ function renderMesa() {
91
+ const agents = Object.values(state.agents);
92
+ const cards = Object.values(state.cards);
93
+ const ownerless = cards.filter((c) => !c.owner && c.state !== "done");
94
+ const now = Date.now();
95
+ const NS = "http://www.w3.org/2000/svg";
96
+ const svg = el.mesaSvg;
97
+ svg.innerHTML = "";
98
+ el.mesaCount.textContent = agents.length
99
+ ? `${agents.length} agentes ${SYM.middot} ${ownerless.length} cards sin owner`
100
+ : "sin agentes";
101
+
102
+ const mk = (tag, attrs) => {
103
+ const e = document.createElementNS(NS, tag);
104
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
105
+ return e;
106
+ };
107
+ const cx = 180, cy = 168;
108
+
109
+ // la mesa
110
+ svg.appendChild(mk("ellipse", { cx, cy, rx: 84, ry: 58, fill: "var(--surface)", stroke: "var(--border-strong)", "stroke-width": "1.5" }));
111
+ const label = mk("text", { x: cx, y: cy - 4, "text-anchor": "middle", fill: "var(--text)", "font-family": "var(--font-display)", "font-weight": "700", "font-size": "13" });
112
+ label.textContent = "LA MESA";
113
+ svg.appendChild(label);
114
+ const sub = mk("text", { x: cx, y: cy + 14, "text-anchor": "middle", fill: "var(--text-3)", "font-size": "8" });
115
+ sub.textContent = state.runId ? "run " + state.runId.slice(0, 10) : "sin corrida";
116
+ svg.appendChild(sub);
117
+
118
+ // agentes alrededor de la mesa
119
+ const n = Math.max(agents.length, 1);
120
+ const r = 120;
121
+ agents.forEach((a, i) => {
122
+ const ang = (i / n) * Math.PI * 2 - Math.PI / 2;
123
+ const x = cx + Math.cos(ang) * r * 1.35;
124
+ const y = cy + Math.sin(ang) * r * 0.86;
125
+ const stale = a.lastHeartbeat && (now - new Date(a.lastHeartbeat).getTime()) > HEARTBEAT_STALE_MS;
126
+ const st = stale ? "stale" : (a.state === "working" ? "working" : "idle_available");
127
+ const color = st === "working" ? "var(--accent)" : st === "idle_available" ? "var(--ok)" : "var(--err)";
128
+
129
+ const g = mk("g", { class: "agent-node", "data-state": st });
130
+ const title = document.createElementNS(NS, "title");
131
+ title.textContent = `${a.displayName || a.id} ${SYM.middot} ${st} ${SYM.middot} ${a.currentCard ? "en " + a.currentCard : "libre"}`;
132
+ g.appendChild(title);
133
+
134
+ if (st === "working") {
135
+ g.appendChild(mk("circle", { cx: x, cy: y, r: 19, fill: "none", stroke: color, "stroke-width": "1", opacity: "0.5", class: "pulse" }));
136
+ }
137
+ g.appendChild(mk("circle", { cx: x, cy: y, r: 14, fill: "var(--surface-2)", stroke: color, "stroke-width": "1.5" }));
138
+ const ini = mk("text", { x, y: y + 4, "text-anchor": "middle", fill: "var(--text)", "font-size": "10", "font-weight": "700" });
139
+ ini.textContent = (a.displayName || a.id).slice(0, 2).toUpperCase();
140
+ g.appendChild(ini);
141
+ const nm = mk("text", { x, y: y + 27, "text-anchor": "middle", fill: "var(--text-2)", "font-size": "9" });
142
+ nm.textContent = (a.displayName || a.id).slice(0, 16);
143
+ g.appendChild(nm);
144
+ const cs = mk("text", { x, y: y + 37, "text-anchor": "middle", fill: a.currentCard ? "var(--accent)" : "var(--text-3)", "font-size": "8" });
145
+ cs.textContent = stale ? "sin heartbeat" : (a.currentCard || "libre");
146
+ g.appendChild(cs);
147
+ svg.appendChild(g);
148
+ });
149
+
150
+ // cards sin owner: fila inferior
151
+ ownerless.forEach((c, i) => {
152
+ const x = 40 + i * 30, y = 336;
153
+ const g = mk("g", { class: "agent-node", "data-state": "ownerless" });
154
+ const title = document.createElementNS(NS, "title");
155
+ title.textContent = `${c.id} sin owner, lista para reclamar`;
156
+ g.appendChild(title);
157
+ g.appendChild(mk("rect", { x: x - 11, y: y - 8, width: 22, height: 16, rx: 3, fill: "var(--surface-2)", stroke: "var(--card-escalated)", "stroke-width": "1" }));
158
+ const tx = mk("text", { x, y: y + 3, "text-anchor": "middle", fill: "var(--card-escalated)", "font-size": "8", "font-weight": "700" });
159
+ tx.textContent = c.id.replace("T-", "");
160
+ g.appendChild(tx);
161
+ svg.appendChild(g);
162
+ });
163
+ }
164
+
165
+ /* ============================================================
166
+ RENDER: bus
167
+ ============================================================ */
168
+ function renderBus() {
169
+ const msgs = state.bus;
170
+ if (Number(el.busStream.dataset.len || 0) === msgs.length) return;
171
+ el.busStream.dataset.len = msgs.length;
172
+ // rendimiento: cap de render (guideline: listas grandes se virtualizan/cortan)
173
+ const MAX = 200;
174
+ const shown = msgs.slice(-MAX);
175
+ el.busCount.textContent = shown.length < msgs.length
176
+ ? `${shown.length} de ${msgs.length} msgs`
177
+ : `${msgs.length} msgs`;
178
+ el.mMsgs.textContent = msgs.length;
179
+ el.busStream.innerHTML = "";
180
+
181
+ const frag = document.createDocumentFragment();
182
+ const replies = new Map();
183
+ shown.forEach((m) => { if (m.inReplyTo) { if (!replies.has(m.inReplyTo)) replies.set(m.inReplyTo, []); replies.get(m.inReplyTo).push(m); } });
184
+
185
+ const makeMsg = (m, depth) => {
186
+ const div = document.createElement("article");
187
+ div.className = "msg" + (depth > 0 ? " threaded" : "");
188
+ div.dataset.priority = m.priority || "normal";
189
+ div.tabIndex = 0;
190
+ const body = m.body?.text || m.body || "";
191
+ const to = (m.to || []).includes("*") ? "pool" : (m.to || []).map(shortName).join(", ");
192
+ const prio = m.priority && m.priority !== "normal" ? `<span class="prio" data-prio="${esc(m.priority)}">${esc(m.priority)}</span>` : "";
193
+ div.innerHTML = `
194
+ <div class="head">
195
+ <span class="kind" data-kind="${esc(m.kind || "NOTICE")}">${esc(m.kind || "NOTICE")}</span>
196
+ <span class="route">${esc(shortName(m.from))} <span class="arrow" aria-hidden="true">${SYM.arrow}</span> ${esc(to)}</span>
197
+ ${prio}
198
+ <span class="time">${esc(fmtTime(m.createdAt))}</span>
199
+ </div>
200
+ <div class="subject">${esc(m.subject || "")}</div>
201
+ <div class="body">${esc(body)}</div>`;
202
+ div.addEventListener("click", () => div.classList.toggle("expanded"));
203
+ div.addEventListener("keydown", (e) => {
204
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); div.classList.toggle("expanded"); }
205
+ });
206
+ frag.appendChild(div);
207
+ (replies.get(m.id) || []).forEach((r) => makeMsg(r, depth + 1));
208
+ };
209
+
210
+ msgs.filter((m) => !m.inReplyTo).forEach((m) => makeMsg(m, 0));
211
+ el.busStream.appendChild(frag);
212
+ }
213
+
214
+ /* ============================================================
215
+ RENDER: feed del jefe
216
+ ============================================================ */
217
+ function renderChief() {
218
+ const msgs = state.bus.filter((m) => JEFES.has(m.from));
219
+ if (Number(el.chiefFeed.dataset.len || 0) === msgs.length) return;
220
+ el.chiefFeed.dataset.len = msgs.length;
221
+ el.chiefCount.textContent = String(msgs.length);
222
+ el.chiefFeed.innerHTML = "";
223
+ const frag = document.createDocumentFragment();
224
+ msgs.forEach((m) => {
225
+ const role = m.from.replace(/^oficina-/, "");
226
+ const who = role.charAt(0).toUpperCase() + role.slice(1);
227
+ const body = m.body?.text || m.body || "";
228
+ const div = document.createElement("article");
229
+ div.className = "chief-item";
230
+ div.dataset.who = who;
231
+ div.innerHTML = `
232
+ <div class="who">${esc(who)}</div>
233
+ <div class="what"><b>${esc(m.subject || m.kind)}</b><br>${esc(body.slice(0, 200))}${body.length > 200 ? "..." : ""}</div>`;
234
+ frag.appendChild(div);
235
+ });
236
+ el.chiefFeed.appendChild(frag);
237
+ }
238
+
239
+ /* ============================================================
240
+ RENDER: kanban
241
+ ============================================================ */
242
+ function renderBoard() {
243
+ const cards = Object.values(state.cards);
244
+ el.board.innerHTML = "";
245
+ const frag = document.createDocumentFragment();
246
+
247
+ for (const col of COLS) {
248
+ const colCards = cards.filter((c) => c.state === col);
249
+ const colEl = document.createElement("div");
250
+ colEl.className = "col";
251
+ colEl.dataset.state = col;
252
+ colEl.setAttribute("role", "list");
253
+ colEl.setAttribute("aria-label", `Columna ${col}, ${colCards.length} cards`);
254
+ const head = document.createElement("header");
255
+ head.innerHTML = `<i aria-hidden="true"></i><span>${esc(col)}</span><span class="n">${colCards.length}</span>`;
256
+ colEl.appendChild(head);
257
+
258
+ colCards.forEach((c) => {
259
+ const card = document.createElement("article");
260
+ card.className = "card";
261
+ card.dataset.state = c.state;
262
+ card.setAttribute("role", "listitem");
263
+ card.tabIndex = 0;
264
+ const rej = c.rejections > 0 ? `<span class="rejected">${SYM.rework} ${c.rejections}</span>` : "";
265
+ const ac = (c.acceptance || []).slice(0, 4).map((a) => `<label><input type="checkbox" disabled aria-label="Criterio pendiente de verificaci&oacute;n"><span>${esc(a.slice(0, 60))}</span></label>`).join("");
266
+ card.innerHTML = `
267
+ <div class="cid"><span>${esc(c.id)}</span>${rej}${c.attempts > 1 ? `<span>intento ${c.attempts}</span>` : ""}</div>
268
+ <div class="title">${esc(c.title)}</div>
269
+ <div class="people">
270
+ <span>${SYM.user} ${c.owner ? esc(shortName(c.owner)) : '<span class="none">sin owner</span>'}</span>
271
+ ${c.reviewer ? `<span>${SYM.lens} ${esc(shortName(c.reviewer))}</span>` : ""}
272
+ </div>
273
+ ${ac ? `<div class="ac">${ac}</div>` : ""}`;
274
+ card.addEventListener("click", () => showGates(c));
275
+ card.addEventListener("keydown", (e) => {
276
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); showGates(c); }
277
+ });
278
+ colEl.appendChild(card);
279
+ });
280
+ frag.appendChild(colEl);
281
+ }
282
+ el.board.appendChild(frag);
283
+
284
+ el.mCards.textContent = String(cards.length);
285
+ el.mDone.textContent = String(cards.filter((c) => c.state === "done").length);
286
+ el.mBlocked.textContent = String(cards.filter((c) => c.state === "blocked" || c.state === "escalated").length);
287
+ }
288
+
289
+ /* ============================================================
290
+ Gates: parse de .oficina/artifacts/<id>/gates.md
291
+ ============================================================ */
292
+ function showGates(card) {
293
+ el.gates.querySelectorAll(".gate-row").forEach((g) => g.remove());
294
+ el.linkEvidence.hidden = true;
295
+ el.linkHandoff.hidden = true;
296
+ if (!card?.id) return;
297
+ fetch(`api/artifacts?card=${encodeURIComponent(card.id)}`)
298
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error("sin gates"))))
299
+ .then((data) => {
300
+ (data.gates || []).forEach((g) => {
301
+ const row = document.createElement("div");
302
+ row.className = "gate-row";
303
+ row.dataset.status = g.status;
304
+ const icon = g.status === "pass" ? SYM.check : g.status === "fail" ? SYM.cross : "";
305
+ row.innerHTML = `
306
+ <span class="icon" aria-hidden="true">${icon}</span>
307
+ <span class="name">${esc(g.name)}</span>
308
+ <span class="status">${esc(g.status)}</span>`;
309
+ el.gates.prepend(row);
310
+ });
311
+ if (data.gates?.length) toast(data.gates.every((g) => g.status === "pass") ? "gate" : "gate-fail", `${card.id}: gates ${data.gates.every((g) => g.status === "pass") ? "PASS" : "con FAIL"}`);
312
+ el.linkEvidence.hidden = !data.evidence;
313
+ el.linkHandoff.hidden = !data.handoff;
314
+ el.linkEvidence.href = `api/evidence?card=${encodeURIComponent(card.id)}`;
315
+ el.linkHandoff.href = `api/handoff?card=${encodeURIComponent(card.id)}`;
316
+ el.linkEvidence.target = "_blank";
317
+ el.linkHandoff.target = "_blank";
318
+ el.linkEvidence.rel = "noopener";
319
+ el.linkHandoff.rel = "noopener";
320
+ })
321
+ .catch(() => {});
322
+ }
323
+
324
+ /* ============================================================
325
+ RENDER master + snapshot para replay
326
+ ============================================================ */
327
+ let knownAgents = new Set();
328
+ let knownCards = new Map();
329
+
330
+ function detectEvents(prevA, prevC) {
331
+ for (const id of Object.keys(state.agents)) {
332
+ if (!prevA.has(id)) toast("spawn", `Factory materializa agente: ${shortName(id)}`, 6000);
333
+ }
334
+ for (const [id, c] of Object.entries(state.cards)) {
335
+ const prev = prevC.get(id);
336
+ if (prev && prev.state !== "done" && c.state === "done") toast("gate", `${id} ${SYM.arrow} DONE`);
337
+ if (prev && (c.rejections || 0) > (prev.rejections || 0)) toast("gate-fail", `${id} rechazada, vuelve a rework`);
338
+ }
339
+ knownAgents = new Set(Object.keys(state.agents));
340
+ knownCards = new Map(Object.entries(state.cards).map(([k, v]) => [k, { state: v.state, rejections: v.rejections }]));
341
+ }
342
+
343
+ function render() {
344
+ renderMesa();
345
+ renderBus();
346
+ renderChief();
347
+ renderBoard();
348
+ el.runId.textContent = state.runId ? "run " + state.runId.slice(0, 10) : "sin corrida";
349
+ snapshot();
350
+ }
351
+
352
+ function snapshot() {
353
+ if (state.replaying) return;
354
+ const last = state.bus[state.bus.length - 1];
355
+ if (!last) return;
356
+ if (snapshots.length && snapshots[snapshots.length - 1].msg.id === last.id) return;
357
+ snapshots.push({ t: last.createdAt, msg: last });
358
+ el.replayRange.max = String(snapshots.length - 1);
359
+ }
360
+
361
+ /* ============================================================
362
+ REPLAY: scrub del bus
363
+ ============================================================ */
364
+ let replayIdx = 0;
365
+ let replayTimer = null;
366
+
367
+ function enterReplay() {
368
+ if (snapshots.length < 2) { toast("gate", "Todavia no hay sesion grabada"); return; }
369
+ state.replaying = true;
370
+ el.replay.hidden = false;
371
+ el.btnReplay.setAttribute("aria-pressed", "true");
372
+ replayIdx = 0;
373
+ scrubReplay();
374
+ }
375
+ function exitReplay() {
376
+ state.replaying = false;
377
+ el.replay.hidden = true;
378
+ el.btnReplay.setAttribute("aria-pressed", "false");
379
+ if (replayTimer) { clearInterval(replayTimer); replayTimer = null; el.btnPlay.innerHTML = SYM.play; }
380
+ render();
381
+ }
382
+ function scrubReplay() {
383
+ const snap = snapshots[replayIdx];
384
+ if (!snap) return;
385
+ el.replayRange.value = String(replayIdx);
386
+ el.replayClock.textContent = fmtTime(snap.t) + " " + SYM.middot + " msg " + (replayIdx + 1) + "/" + snapshots.length;
387
+ const full = state.bus;
388
+ state.bus = full.filter((m) => new Date(m.createdAt) <= new Date(snap.t));
389
+ renderBus();
390
+ state.bus = full;
391
+ }
392
+ el.replayRange.addEventListener("input", () => { replayIdx = Number(el.replayRange.value); scrubReplay(); });
393
+ el.btnPlay.addEventListener("click", () => {
394
+ if (replayTimer) { clearInterval(replayTimer); replayTimer = null; el.btnPlay.innerHTML = SYM.play; el.btnPlay.setAttribute("aria-label", "Reproducir replay"); return; }
395
+ el.btnPlay.innerHTML = SYM.pause;
396
+ el.btnPlay.setAttribute("aria-label", "Pausar replay");
397
+ replayTimer = setInterval(() => {
398
+ replayIdx++;
399
+ if (replayIdx >= snapshots.length - 1) { clearInterval(replayTimer); replayTimer = null; el.btnPlay.innerHTML = SYM.play; el.btnPlay.setAttribute("aria-label", "Reproducir replay"); }
400
+ scrubReplay();
401
+ }, 450);
402
+ });
403
+ el.btnReplay.addEventListener("click", () => (state.replaying ? exitReplay() : enterReplay()));
404
+
405
+ /* ============================================================
406
+ Conexión: server.mjs (SSE) o demo
407
+ ============================================================ */
408
+ async function loadViaServer() {
409
+ const res = await fetch("api/state");
410
+ if (!res.ok) throw new Error("sin server");
411
+ const data = await res.json();
412
+ state.runId = data.runId;
413
+ state.cards = data.cards || {};
414
+ state.agents = data.agents || {};
415
+ state.bus = data.bus || [];
416
+ render();
417
+ detectEvents(knownAgents, knownCards);
418
+ return true;
419
+ }
420
+
421
+ function startConnection() {
422
+ el.conn.dataset.state = "poll";
423
+ el.connLabel.textContent = "SSE";
424
+ let es;
425
+ try {
426
+ es = new EventSource("api/events");
427
+ es.addEventListener("change", () => loadViaServer().catch(() => {}));
428
+ es.onerror = () => { el.conn.dataset.state = "offline"; el.connLabel.textContent = "reconectando"; };
429
+ } catch { startPolling(); }
430
+ startPolling();
431
+ }
432
+ function startPolling() {
433
+ const poll = () => loadViaServer().catch(() => { if (!state.demo) runDemo(); });
434
+ poll();
435
+ setInterval(poll, 5000);
436
+ }
437
+
438
+ /* ============================================================
439
+ DEMO: simulador honesto (banner visible, etiquetado)
440
+ ============================================================ */
441
+ function runDemo() {
442
+ state.demo = true;
443
+ el.demoBanner.hidden = false;
444
+ el.conn.dataset.state = "demo";
445
+ el.connLabel.textContent = "demo";
446
+ el.runId.textContent = "demo";
447
+
448
+ const agents = [
449
+ { id: "oficina-director", displayName: "Director", state: "idle_available" },
450
+ { id: "ag-backend", displayName: "Backend", state: "working" },
451
+ { id: "ag-frontend", displayName: "Frontend", state: "working" },
452
+ { id: "ag-qa", displayName: "QA", state: "idle_available" },
453
+ { id: "ag-reviewer", displayName: "Critico", state: "idle_available" },
454
+ ];
455
+ agents.forEach((a, i) => {
456
+ state.agents[a.id] = {
457
+ ...a,
458
+ currentCard: i > 0 && i < 3 ? `T-0${i - 1}` : null,
459
+ lastHeartbeat: new Date().toISOString(),
460
+ role: a.id,
461
+ };
462
+ });
463
+
464
+ state.cards = {
465
+ "T-00": { id: "T-00", title: "Cablear el guard anti-gate-verde-vacio en el runner de gates", state: "done", owner: "ag-backend", reviewer: "ag-reviewer", rejections: 1, attempts: 2, acceptance: ["guard activo en runTechnicalGates", "tests de regresion en verde"], artifacts: ".oficina/artifacts/T-00" },
466
+ "T-01": { id: "T-01", title: "Dashboard visual: mesa + bus + kanban en tiempo real", state: "in_progress", owner: "ag-frontend", reviewer: null, rejections: 0, attempts: 1, acceptance: ["mesa poblada", "bus con threading", "kanban 7 columnas"], artifacts: "" },
467
+ "T-02": { id: "T-02", title: "Server SSE + watch recursivo de .oficina/", state: "review", owner: "ag-backend", reviewer: "ag-reviewer", rejections: 0, attempts: 1, acceptance: ["SSE emite change", "cero dependencias"], artifacts: ".oficina/artifacts/T-02" },
468
+ "T-03": { id: "T-03", title: "Modo replay con scrub del bus", state: "todo", owner: null, reviewer: null, rejections: 0, attempts: 0, acceptance: ["slider recorre la sesion"], artifacts: "" },
469
+ "T-04": { id: "T-04", title: "Fondo WebGL con bloom en la mesa", state: "blocked", owner: "ag-frontend", reviewer: null, rejections: 2, attempts: 3, acceptance: ["bloom sutil", "fallback a 2D"], artifacts: "" },
470
+ };
471
+
472
+ const t = (min) => new Date(Date.now() - min * 60000).toISOString();
473
+ state.bus = [
474
+ { id: "d1", from: "oficina-director", to: ["*"], kind: "NOTICE", subject: "Despacho: 4 cards, equipo de 4", body: { text: "T-00 a T-04 en el tablero. Factory on standby si hay card sin match." }, createdAt: t(30), priority: "normal", inReplyTo: null },
475
+ { id: "d2", from: "ag-backend", to: ["*"], kind: "HELP_REQUEST", subject: "T-00: donde vive el guard del runner?", body: { text: "Necesito saber donde cablear el guard anti-gate-verde-vacio antes de tocar gates.ts." }, createdAt: t(24), priority: "high", inReplyTo: null },
476
+ { id: "d3", from: "oficina-architect", to: ["ag-backend"], kind: "ANSWER", subject: "RE: T-00: donde vive el guard", body: { text: "En src/core/office.ts, funcion runTechnicalGates. Los tests van en tests/gates.test.ts." }, createdAt: t(23), priority: "normal", inReplyTo: "d2" },
477
+ { id: "d4", from: "ag-reviewer", to: ["ag-backend"], kind: "STATUS", subject: "T-00 RECHAZADA (rework 1): guard degrada salidas verdes", body: { text: "La fase test reporta PASS sin correr nada. El guard debe detectar cero tests y marcar not_run." }, createdAt: t(18), priority: "high", inReplyTo: null },
478
+ { id: "d5", from: "oficina-integrator", to: ["*"], kind: "CLAIM", subject: "T-01 reclamada por ag-frontend", body: { text: "Lease activo en T-01, mesa actualizada." }, createdAt: t(10), priority: "normal", inReplyTo: null },
479
+ ];
480
+
481
+ render();
482
+
483
+ // loop demo: mensajes nuevos + transiciones de estado
484
+ const script = [
485
+ { kind: "HELP_REQUEST", from: "ag-frontend", to: ["*"], subject: "T-01: threading visual del bus", body: "Como represento inReplyTo en la lista? Indentado o con conector?", priority: "normal", replyTo: null },
486
+ { kind: "ANSWER", from: "oficina-architect", to: ["ag-frontend"], subject: "RE: T-01 threading", body: "Indentado con conector de rama. La respuesta viaja dentro del thread del padre.", priority: "normal", replyTo: "d5" },
487
+ { kind: "STATUS", from: "ag-reviewer", to: ["ag-backend"], subject: "T-02 APROBADA", body: "Gates en verde, review completo en artifacts. Pasa a DONE.", priority: "normal", replyTo: null },
488
+ { kind: "SPAWN_REQUEST", from: "oficina-director", to: ["*"], subject: "Factory: agente visualizador-dashboard", body: "No hay match en el catalogo para dashboards temps-real. Fabricando agente persistente.", priority: "high", replyTo: null },
489
+ { kind: "NOTICE", from: "oficina-integrator", to: ["*"], subject: "T-03 lista para reclamar", body: "Card sin owner visible en la mesa. Pool libre: ag-qa.", priority: "normal", replyTo: null },
490
+ ];
491
+ let si = 0;
492
+ let phase = 0;
493
+ setInterval(() => {
494
+ if (state.replaying) return;
495
+ const s = script[si % script.length];
496
+ si++;
497
+ const id = "dm" + Date.now();
498
+ state.bus.push({
499
+ id, from: s.from, to: s.to, kind: s.kind, subject: s.subject,
500
+ body: { text: s.body }, createdAt: new Date().toISOString(),
501
+ priority: s.priority, inReplyTo: s.replyTo,
502
+ });
503
+ if (s.kind === "SPAWN_REQUEST") {
504
+ state.agents["ag-visualizer"] = { id: "ag-visualizer", displayName: "Visualizer", state: "idle_available", currentCard: null, lastHeartbeat: new Date().toISOString(), role: "visualizador-dashboard" };
505
+ }
506
+ if (s.kind === "STATUS" && s.subject.includes("T-02")) {
507
+ state.cards["T-02"].state = "done";
508
+ state.agents["ag-backend"].state = "idle_available";
509
+ state.agents["ag-backend"].currentCard = null;
510
+ }
511
+ if (s.kind === "NOTICE" && phase === 0) {
512
+ state.cards["T-03"].state = "in_progress";
513
+ state.cards["T-03"].owner = "ag-qa";
514
+ state.agents["ag-qa"].state = "working";
515
+ state.agents["ag-qa"].currentCard = "T-03";
516
+ phase = 1;
517
+ }
518
+ // heartbeat del demo
519
+ Object.values(state.agents).forEach((a) => {
520
+ if (a.state === "working") a.lastHeartbeat = new Date().toISOString();
521
+ });
522
+ render();
523
+ detectEvents(knownAgents, knownCards);
524
+ }, 4000);
525
+ }
526
+
527
+ /* ============================================================
528
+ Fondo WebGL (three.js + bloom, degrada a nada)
529
+ ============================================================ */
530
+ async function initWebGL() {
531
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
532
+ try {
533
+ const THREE = await import("https://esm.sh/three@0.161.0");
534
+ const { EffectComposer } = await import("https://esm.sh/three@0.161.0/examples/jsm/postprocessing/EffectComposer.js");
535
+ const { RenderPass } = await import("https://esm.sh/three@0.161.0/examples/jsm/postprocessing/RenderPass.js");
536
+ const { UnrealBloomPass } = await import("https://esm.sh/three@0.161.0/examples/jsm/postprocessing/UnrealBloomPass.js");
537
+ const { OutputPass } = await import("https://esm.sh/three@0.161.0/examples/jsm/postprocessing/OutputPass.js");
538
+
539
+ const canvas = document.createElement("canvas");
540
+ canvas.setAttribute("aria-hidden", "true");
541
+ canvas.style.cssText = "position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.4;";
542
+ document.body.prepend(canvas);
543
+
544
+ const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
545
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
546
+ renderer.setSize(window.innerWidth, window.innerHeight);
547
+
548
+ const scene = new THREE.Scene();
549
+ const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
550
+ camera.position.z = 10;
551
+
552
+ // campo de puntos ámbar: el "campo de trabajo" de la oficina
553
+ const N = 180;
554
+ const pos = new Float32Array(N * 3);
555
+ for (let i = 0; i < N; i++) {
556
+ pos[i * 3] = (Math.random() - 0.5) * 24;
557
+ pos[i * 3 + 1] = (Math.random() - 0.5) * 14;
558
+ pos[i * 3 + 2] = (Math.random() - 0.5) * 8;
559
+ }
560
+ const geo = new THREE.BufferGeometry();
561
+ geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
562
+ const mat = new THREE.PointsMaterial({
563
+ color: 0xf59e0b, size: 0.05, transparent: true, opacity: 0.7,
564
+ blending: THREE.AdditiveBlending, depthWrite: false,
565
+ });
566
+ const points = new THREE.Points(geo, mat);
567
+ scene.add(points);
568
+
569
+ const composer = new EffectComposer(renderer);
570
+ composer.addPass(new RenderPass(scene, camera));
571
+ composer.addPass(new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.9, 0.5, 0.85));
572
+ composer.addPass(new OutputPass());
573
+
574
+ const clock = new THREE.Clock();
575
+ renderer.setAnimationLoop(() => {
576
+ const t = clock.getElapsedTime() * 0.05;
577
+ points.rotation.y = t;
578
+ points.rotation.x = Math.sin(clock.getElapsedTime() * 0.02) * 0.1;
579
+ composer.render();
580
+ });
581
+ window.addEventListener("resize", () => {
582
+ camera.aspect = window.innerWidth / window.innerHeight;
583
+ camera.updateProjectionMatrix();
584
+ renderer.setSize(window.innerWidth, window.innerHeight);
585
+ composer.setSize(window.innerWidth, window.innerHeight);
586
+ });
587
+ } catch {
588
+ /* sin red o sin WebGL: la mesa funciona igual (fallback honesto) */
589
+ }
590
+ }
591
+
592
+ /* ---------------- Atajos ---------------- */
593
+ document.addEventListener("keydown", (e) => {
594
+ if (e.target.matches("input, textarea")) return;
595
+ if (e.key === "t" || e.key === "T") el.btnTheme.click();
596
+ if (e.key === "r" || e.key === "R") el.btnReplay.click();
597
+ if (e.key === "Escape" && state.replaying) exitReplay();
598
+ });
599
+
600
+ /* ---------------- Boot ---------------- */
601
+ async function boot() {
602
+ try {
603
+ await loadViaServer();
604
+ startConnection();
605
+ } catch {
606
+ runDemo();
607
+ }
608
+ initWebGL();
609
+ }
610
+ boot();