oficina-opencode 3.0.5 → 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 (145) hide show
  1. package/README.md +240 -208
  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 +148 -26
  43. package/dist/cli.js.map +1 -1
  44. package/dist/core/bus.js +3 -3
  45. package/dist/core/bus.js.map +1 -1
  46. package/dist/core/catalog.d.ts +9 -1
  47. package/dist/core/catalog.d.ts.map +1 -1
  48. package/dist/core/catalog.js +77 -2
  49. package/dist/core/catalog.js.map +1 -1
  50. package/dist/core/dashboard.d.ts +33 -0
  51. package/dist/core/dashboard.d.ts.map +1 -0
  52. package/dist/core/dashboard.js +115 -0
  53. package/dist/core/dashboard.js.map +1 -0
  54. package/dist/core/dispatch.d.ts +1 -1
  55. package/dist/core/dispatch.d.ts.map +1 -1
  56. package/dist/core/dispatch.js +84 -12
  57. package/dist/core/dispatch.js.map +1 -1
  58. package/dist/core/docsync.d.ts +0 -9
  59. package/dist/core/docsync.d.ts.map +1 -1
  60. package/dist/core/docsync.js +12 -15
  61. package/dist/core/docsync.js.map +1 -1
  62. package/dist/core/environment.d.ts +28 -0
  63. package/dist/core/environment.d.ts.map +1 -0
  64. package/dist/core/environment.js +80 -0
  65. package/dist/core/environment.js.map +1 -0
  66. package/dist/core/factory.d.ts +16 -0
  67. package/dist/core/factory.d.ts.map +1 -1
  68. package/dist/core/factory.js +143 -0
  69. package/dist/core/factory.js.map +1 -1
  70. package/dist/core/fsx.d.ts +20 -2
  71. package/dist/core/fsx.d.ts.map +1 -1
  72. package/dist/core/fsx.js +10 -6
  73. package/dist/core/fsx.js.map +1 -1
  74. package/dist/core/gates.d.ts +23 -1
  75. package/dist/core/gates.d.ts.map +1 -1
  76. package/dist/core/gates.js +83 -8
  77. package/dist/core/gates.js.map +1 -1
  78. package/dist/core/leases.d.ts.map +1 -1
  79. package/dist/core/leases.js +4 -1
  80. package/dist/core/leases.js.map +1 -1
  81. package/dist/core/mesa.d.ts +1 -6
  82. package/dist/core/mesa.d.ts.map +1 -1
  83. package/dist/core/mesa.js +1 -6
  84. package/dist/core/mesa.js.map +1 -1
  85. package/dist/core/office.d.ts +3 -29
  86. package/dist/core/office.d.ts.map +1 -1
  87. package/dist/core/office.js +77 -90
  88. package/dist/core/office.js.map +1 -1
  89. package/dist/core/paths.d.ts.map +1 -1
  90. package/dist/core/paths.js +6 -1
  91. package/dist/core/paths.js.map +1 -1
  92. package/dist/core/retry.d.ts +2 -8
  93. package/dist/core/retry.d.ts.map +1 -1
  94. package/dist/core/retry.js +3 -34
  95. package/dist/core/retry.js.map +1 -1
  96. package/dist/core/roster.js +3 -3
  97. package/dist/core/roster.js.map +1 -1
  98. package/dist/core/sandbox.d.ts +38 -1
  99. package/dist/core/sandbox.d.ts.map +1 -1
  100. package/dist/core/sandbox.js +119 -15
  101. package/dist/core/sandbox.js.map +1 -1
  102. package/dist/core/state.d.ts +2 -2
  103. package/dist/core/state.d.ts.map +1 -1
  104. package/dist/core/state.js +5 -33
  105. package/dist/core/state.js.map +1 -1
  106. package/dist/core/stores.d.ts +3 -13
  107. package/dist/core/stores.d.ts.map +1 -1
  108. package/dist/core/stores.js +8 -32
  109. package/dist/core/stores.js.map +1 -1
  110. package/dist/core/systemPrompt.d.ts.map +1 -1
  111. package/dist/core/systemPrompt.js +4 -1
  112. package/dist/core/systemPrompt.js.map +1 -1
  113. package/dist/core/tripwire.d.ts +22 -8
  114. package/dist/core/tripwire.d.ts.map +1 -1
  115. package/dist/core/tripwire.js +71 -26
  116. package/dist/core/tripwire.js.map +1 -1
  117. package/dist/core/types.d.ts +6 -14
  118. package/dist/core/types.d.ts.map +1 -1
  119. package/dist/core/types.js +11 -3
  120. package/dist/core/types.js.map +1 -1
  121. package/dist/hooks/scopeGuard.d.ts +15 -1
  122. package/dist/hooks/scopeGuard.d.ts.map +1 -1
  123. package/dist/hooks/scopeGuard.js +88 -73
  124. package/dist/hooks/scopeGuard.js.map +1 -1
  125. package/dist/tools/index.d.ts +9 -1
  126. package/dist/tools/index.d.ts.map +1 -1
  127. package/dist/tools/index.js +29 -81
  128. package/dist/tools/index.js.bak +972 -0
  129. package/dist/tools/index.js.map +1 -1
  130. package/dist/v1.d.ts.map +1 -1
  131. package/dist/v1.js +30 -36
  132. package/dist/v1.js.map +1 -1
  133. package/dist/v2.d.ts +5 -0
  134. package/dist/v2.d.ts.map +1 -1
  135. package/dist/v2.js +79 -46
  136. package/dist/v2.js.bak +517 -0
  137. package/dist/v2.js.map +1 -1
  138. package/docs/GARANTIAS.md +2 -2
  139. package/docs/PRUEBA-E2E.md +4 -4
  140. package/docs/QUE-ES.md +4 -5
  141. package/docs/SEGURIDAD-REPO.md +83 -0
  142. package/docs/assets/banner.svg +42 -0
  143. package/docs/catalog-facts.json +3 -3
  144. package/docs/superpowers/specs/2026-10-29-factory-skill-creator-and-remove-budget.md +182 -0
  145. package/package.json +14 -6
@@ -0,0 +1,244 @@
1
+ import test from "node:test"
2
+ import assert from "node:assert/strict"
3
+
4
+ // The core is a classic script (works from file://); importing it for its side
5
+ // effect publishes globalThis.FactoryCore.
6
+ import "../factory/factory-core.js"
7
+
8
+ const {
9
+ COVERAGE_THRESHOLD,
10
+ normalizeCapability,
11
+ parseCards,
12
+ parseAgents,
13
+ computeCoverage,
14
+ detectFactoryNeed,
15
+ synthesizeAgent,
16
+ registerAgent,
17
+ projectMatch,
18
+ renderFichaMarkdown,
19
+ renderFichaLines,
20
+ } = globalThis.FactoryCore
21
+
22
+ test("el core publica su API en globalThis.FactoryCore", () => {
23
+ assert.equal(typeof globalThis.FactoryCore, "object")
24
+ assert.equal(typeof globalThis.FactoryCore.detectFactoryNeed, "function")
25
+ assert.equal(COVERAGE_THRESHOLD, 0.6)
26
+ })
27
+
28
+ const roster = {
29
+ version: 2,
30
+ agents: {
31
+ "e-a": {
32
+ id: "e-a",
33
+ role: "api-tester",
34
+ displayName: "API Tester",
35
+ state: "idle_available",
36
+ capabilities: ["api", "testing", "node", "typescript"],
37
+ fichaPath: ".opencode/agents/oficina-api-tester.md",
38
+ },
39
+ },
40
+ }
41
+
42
+ const state = {
43
+ version: 3,
44
+ order: ["T-01", "T-02", "T-03"],
45
+ cards: {
46
+ "T-01": {
47
+ id: "T-01",
48
+ title: "API endpoints",
49
+ type: "implementation",
50
+ state: "todo",
51
+ owner: null,
52
+ requiredCapabilities: ["api", "testing"],
53
+ },
54
+ "T-02": {
55
+ id: "T-02",
56
+ title: "Animar la factory",
57
+ type: "implementation",
58
+ state: "in_progress",
59
+ owner: "e-a",
60
+ requiredCapabilities: ["animation", "svg-canvas", "frontend"],
61
+ },
62
+ "T-03": {
63
+ id: "T-03",
64
+ title: "Kanban con drag-drop",
65
+ type: "implementation",
66
+ state: "todo",
67
+ owner: null,
68
+ requiredCapabilities: ["kanban", "drag-drop"],
69
+ },
70
+ },
71
+ }
72
+
73
+ test("normalizeCapability ignora acentos, mayúsculas y separadores", () => {
74
+ assert.equal(normalizeCapability("SVG-Canvas"), "svgcanvas")
75
+ assert.equal(normalizeCapability("frontend_architecture"), "frontendarchitecture")
76
+ assert.equal(normalizeCapability("Animación"), "animacion")
77
+ assert.equal(normalizeCapability(undefined), "")
78
+ })
79
+
80
+ test("parseCards acepta objeto indexado y respeta order", () => {
81
+ const cards = parseCards(state)
82
+ assert.deepEqual(
83
+ cards.map((c) => c.id),
84
+ ["T-01", "T-02", "T-03"],
85
+ )
86
+ assert.equal(cards[0].isOpen, true)
87
+ assert.equal(cards[1].owner, "e-a")
88
+ })
89
+
90
+ test("parseCards acepta el formato array de snapshots viejos", () => {
91
+ const cards = parseCards({ cards: [state.cards["T-02"]] })
92
+ assert.equal(cards.length, 1)
93
+ assert.equal(cards[0].id, "T-02")
94
+ })
95
+
96
+ test("parseAgents normaliza capabilities y fichaPath", () => {
97
+ const agents = parseAgents(roster)
98
+ assert.equal(agents.length, 1)
99
+ assert.ok(agents[0].capabilityKeys.includes("api"))
100
+ assert.match(agents[0].fichaPath, /oficina-api-tester\.md$/)
101
+ })
102
+
103
+ test("computeCoverage cuenta matches reales del catálogo", () => {
104
+ const agents = parseAgents(roster)
105
+ const card = parseCards(state)[1]
106
+ const coverage = computeCoverage(card, agents)
107
+ assert.equal(coverage.required.length, 3)
108
+ assert.deepEqual(coverage.matched, [])
109
+ assert.deepEqual(coverage.missing, ["animation", "svg-canvas", "frontend"])
110
+ assert.equal(coverage.pct, 0)
111
+ assert.equal(coverage.covered, false)
112
+ })
113
+
114
+ test("computeCoverage sin requisitos está cubierta por definición", () => {
115
+ const coverage = computeCoverage({ requiredCapabilities: [] }, [])
116
+ assert.equal(coverage.covered, true)
117
+ assert.equal(coverage.pct, 100)
118
+ })
119
+
120
+ test("detectFactoryNeed marca las cards bajo el umbral 60%", () => {
121
+ const need = detectFactoryNeed(state, roster)
122
+ assert.equal(need.needs, true)
123
+ assert.equal(need.threshold, COVERAGE_THRESHOLD)
124
+ assert.deepEqual(
125
+ need.gaps.map((g) => g.cardId).sort(),
126
+ ["T-02", "T-03"],
127
+ )
128
+ const t02 = need.gaps.find((g) => g.cardId === "T-02")
129
+ assert.equal(t02.pct, 0)
130
+ assert.deepEqual(t02.missing, ["animation", "svg-canvas", "frontend"])
131
+ // T-02 tiene owner, pero la ficha no respalda el requisito: sigue siendo gap
132
+ assert.equal(t02.owner, "e-a")
133
+ assert.equal(t02.ownerKnown, true)
134
+ })
135
+
136
+ test("detectFactoryNeed no dispara cuando la cobertura alcanza", () => {
137
+ const wide = {
138
+ version: 2,
139
+ agents: {
140
+ ...roster.agents,
141
+ "e-b": {
142
+ id: "e-b",
143
+ role: "motion",
144
+ displayName: "Motion",
145
+ capabilities: ["animation", "svg-canvas", "frontend"],
146
+ },
147
+ },
148
+ }
149
+ const need = detectFactoryNeed(state, wide)
150
+ assert.equal(need.needs, true) // T-03 sigue sin cobertura
151
+ assert.deepEqual(need.gaps.map((g) => g.cardId), ["T-03"])
152
+ assert.equal(
153
+ need.gaps.some((g) => g.cardId === "T-02"),
154
+ false,
155
+ )
156
+ })
157
+
158
+ test("detectFactoryNeed ignora cards done", () => {
159
+ const closed = {
160
+ ...state,
161
+ cards: {
162
+ ...state.cards,
163
+ "T-03": { ...state.cards["T-03"], state: "done" },
164
+ "T-02": { ...state.cards["T-02"], state: "done" },
165
+ },
166
+ }
167
+ const need = detectFactoryNeed(closed, roster)
168
+ assert.equal(need.needs, false)
169
+ assert.deepEqual(need.gaps, [])
170
+ })
171
+
172
+ test("synthesizeAgent es determinístico y elige el rol de motion", () => {
173
+ const agents = parseAgents(roster)
174
+ const card = parseCards(state)[1]
175
+ const a = synthesizeAgent(card, agents, { now: "2026-10-29T00:00:00.000Z" })
176
+ const b = synthesizeAgent(card, agents, { now: "2026-10-29T00:00:00.000Z" })
177
+ assert.deepEqual(a, b)
178
+ assert.equal(a.role, "frontend-motion-engineer")
179
+ assert.equal(a.displayName, "Frontend Motion Engineer")
180
+ assert.equal(a.fichaPath, ".opencode/agents/oficina-frontend-motion-engineer.md")
181
+ assert.equal(a.fabricatedFor, "T-02")
182
+ for (const cap of ["animation", "svg-canvas", "frontend"]) {
183
+ assert.ok(a.capabilities.includes(cap), `falta ${cap}`)
184
+ }
185
+ })
186
+
187
+ test("synthesizeAgent genera ids únicos contra el roster existente", () => {
188
+ const agents = parseAgents(roster)
189
+ const card = parseCards(state)[1]
190
+ const first = synthesizeAgent(card, agents)
191
+ const second = synthesizeAgent(card, [...agents, { ...agents[0], id: first.id }])
192
+ assert.notEqual(first.id, second.id)
193
+ assert.equal(second.id, `${first.id}-2`)
194
+ })
195
+
196
+ test("synthesizeAgent cae a un rol por tipo de card sin match", () => {
197
+ const card = { id: "T-99", type: "bugfix", requiredCapabilities: ["telepatia"] }
198
+ const agent = synthesizeAgent(card, [])
199
+ assert.equal(agent.role, "bugfix-specialist")
200
+ assert.ok(agent.capabilities.includes("telepatia"))
201
+ })
202
+
203
+ test("registerAgent no muta el roster original", () => {
204
+ const agents = parseAgents(roster)
205
+ const agent = synthesizeAgent(parseCards(state)[1], agents, {
206
+ now: "2026-10-29T00:00:00.000Z",
207
+ })
208
+ const next = registerAgent(roster, agent)
209
+ assert.equal(Object.keys(roster.agents).length, 1)
210
+ assert.equal(Object.keys(next.agents).length, 2)
211
+ assert.equal(next.agents[agent.id].state, "idle_available")
212
+ assert.equal(next.agents[agent.id].source.kind, "factory")
213
+ })
214
+
215
+ test("projectMatch muestra el antes/después y el match de la próxima card", () => {
216
+ const agents = parseAgents(roster)
217
+ const agent = synthesizeAgent(parseCards(state)[1], agents, {
218
+ now: "2026-10-29T00:00:00.000Z",
219
+ })
220
+ const projection = projectMatch(state, roster, { fabricated: agent })
221
+
222
+ const t02 = projection.rows.find((r) => r.cardId === "T-02")
223
+ assert.equal(t02.before.covered, false)
224
+ assert.equal(t02.after.covered, true)
225
+ assert.equal(t02.after.pct, 100)
226
+
227
+ const t01 = projection.rows.find((r) => r.cardId === "T-01")
228
+ assert.equal(t01.before.covered, true)
229
+ assert.equal(t01.after.covered, true)
230
+ assert.equal(t01.flipped, false)
231
+
232
+ assert.ok(projection.flipped.length >= 1)
233
+ })
234
+
235
+ test("renderFichaMarkdown produce frontmatter y secciones obligatorias", () => {
236
+ const agent = synthesizeAgent(parseCards(state)[1], parseAgents(roster))
237
+ const md = renderFichaMarkdown(agent, parseCards(state)[1])
238
+ assert.ok(md.startsWith("---\n"))
239
+ assert.match(md, /mode: subagent/)
240
+ assert.ok(md.includes("# Frontend Motion Engineer"))
241
+ assert.ok(md.includes("T-02"))
242
+ assert.ok(md.includes("evidence.md"))
243
+ assert.ok(renderFichaLines(agent, parseCards(state)[1]).length > 10)
244
+ })
@@ -0,0 +1,336 @@
1
+ /*
2
+ * Tests de la lógica del panel de debate (T-05).
3
+ *
4
+ * `debate-core.js` es un script clásico (para servir por file:// y por HTTP),
5
+ * así que se evalúa en un contexto `vm` con el mismo `globalThis` que tendría
6
+ * el navegador. Se corre con: `node --test dashboard/tests`
7
+ */
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import vm from "node:vm";
11
+ import { readFileSync } from "node:fs";
12
+
13
+ const source = readFileSync(new URL("../lib/debate-core.js", import.meta.url), "utf8");
14
+ const context = vm.createContext({ console });
15
+ vm.runInContext(source, context);
16
+ const core = context.DebateCore;
17
+
18
+ // Los arrays creados dentro del contexto `vm` tienen otro prototipo: se
19
+ // convierten al realm del test antes de comparar con deepEqual.
20
+ const arr = (value) => Array.from(value);
21
+
22
+ const NOW = Date.parse("2026-10-29T12:00:00.000Z");
23
+
24
+ function msg(overrides) {
25
+ return {
26
+ id: overrides.id,
27
+ seq: overrides.seq ?? 1,
28
+ from: overrides.from ?? "e-a",
29
+ to: overrides.to ?? ["*"],
30
+ kind: overrides.kind ?? "HELP_REQUEST",
31
+ subject: overrides.subject ?? "T-05",
32
+ body: overrides.body ?? { need: "necesito una mano", why_me: "no me da el scope" },
33
+ replyTo: null,
34
+ inReplyTo: overrides.inReplyTo ?? null,
35
+ ttlSec: overrides.ttlSec ?? 900,
36
+ createdAt: overrides.createdAt ?? "2026-10-29T11:55:00.000Z",
37
+ priority: overrides.priority ?? "normal",
38
+ idempotencyKey: null,
39
+ hops: 0,
40
+ };
41
+ }
42
+
43
+ test("el módulo expone la API esperada", () => {
44
+ for (const fn of ["buildThreads", "isBroadcastHelpRequest", "threadStatus", "filterThreads", "newMessages"]) {
45
+ assert.equal(typeof core[fn], "function", fn);
46
+ }
47
+ });
48
+
49
+ test("detecta HELP_REQUEST broadcast to:['*'] y descarta el resto", () => {
50
+ const broadcast = core.normalizeMessage(msg({ id: "m1" }));
51
+ const direct = core.normalizeMessage(msg({ id: "m2", to: ["e-x"] }));
52
+ const statusBroadcast = core.normalizeMessage(msg({ id: "m3", kind: "STATUS" }));
53
+
54
+ assert.equal(core.isBroadcastHelpRequest(broadcast), true);
55
+ assert.equal(core.isBroadcastHelpRequest(direct), false, "un pedido a un solo agente no es broadcast");
56
+ assert.equal(core.isBroadcastHelpRequest(statusBroadcast), false, "STATUS broadcast no abre debate");
57
+
58
+ assert.equal(core.isDebateRoot(broadcast), true);
59
+ assert.equal(core.isDebateRoot(direct), false);
60
+ });
61
+
62
+ test("normaliza envelopes tolerando basura sin tirar mensajes válidos", () => {
63
+ assert.equal(core.normalizeMessage(null), null);
64
+ assert.equal(core.normalizeMessage({ kind: "HELP_REQUEST" }), null, "sin id no sirve");
65
+ assert.equal(core.normalizeMessage({ id: "m1" }), null, "sin createdAt válido no sirve");
66
+
67
+ const parsed = core.normalizeMessage({
68
+ id: "m1",
69
+ from: "e-a",
70
+ to: "*",
71
+ kind: "help_request",
72
+ createdAt: "2026-10-29T11:55:00.000Z",
73
+ body: null,
74
+ ttlSec: -3,
75
+ priority: "URGENTE",
76
+ });
77
+ assert.deepEqual(arr(parsed.to), ["*"], "acepta to como string");
78
+ assert.equal(parsed.kind, "HELP_REQUEST", "normaliza el kind a mayúsculas");
79
+ assert.equal(parsed.ttlSec, 900, "ttl inválido cae al default");
80
+ assert.equal(parsed.priority, "normal", "prioridad desconocida cae a normal");
81
+ assert.deepEqual({ ...parsed.body }, {});
82
+ });
83
+
84
+ test("anida respuestas bajo el request, incluyendo respuestas a respuestas", () => {
85
+ const snapshot = core.readSnapshot({
86
+ messages: [
87
+ msg({ id: "r1", seq: 1 }),
88
+ msg({ id: "a1", seq: 2, from: "e-b", kind: "HELP_ACCEPT", inReplyTo: "r1", createdAt: "2026-10-29T11:56:00.000Z" }),
89
+ msg({ id: "q1", seq: 3, from: "e-c", kind: "HELP_QUESTION", inReplyTo: "a1", createdAt: "2026-10-29T11:57:00.000Z" }),
90
+ msg({ id: "p1", seq: 4, from: "e-d", kind: "HELP_PARTIAL", inReplyTo: "r1", createdAt: "2026-10-29T11:58:00.000Z" }),
91
+ ],
92
+ });
93
+
94
+ const built = core.buildThreads(snapshot.messages, { now: NOW });
95
+ assert.equal(built.threads.length, 1);
96
+ const thread = built.threads[0];
97
+ assert.equal(thread.id, "r1");
98
+ assert.equal(thread.replyCount, 3, "la respuesta a una respuesta queda en el mismo hilo");
99
+ assert.deepEqual(arr(thread.replies).map((m) => m.id), ["a1", "q1", "p1"], "orden cronológico");
100
+ assert.deepEqual(arr(thread.participants).sort(), ["e-a", "e-b", "e-c", "e-d"]);
101
+ assert.equal(thread.counts.accept, 1);
102
+ assert.equal(thread.counts.partial, 1);
103
+ assert.equal(thread.counts.question, 1);
104
+ assert.equal(built.unlinked.length, 0);
105
+ });
106
+
107
+ test("reengancha huérfanas por subject único y no adivina si es ambiguo", () => {
108
+ const unique = core.buildThreads(
109
+ [
110
+ core.normalizeMessage(msg({ id: "r1", subject: "T-05" })),
111
+ core.normalizeMessage(
112
+ msg({ id: "a1", from: "e-b", kind: "HELP_ACCEPT", inReplyTo: "m-vencido", subject: "T-05", createdAt: "2026-10-29T11:56:00.000Z" }),
113
+ ),
114
+ ],
115
+ { now: NOW },
116
+ );
117
+ assert.equal(unique.threads[0].replyCount, 1, "se reengancha por subject");
118
+ assert.equal(unique.threads[0].replies[0].reattached, true);
119
+ assert.equal(unique.unlinked.length, 0);
120
+
121
+ const ambiguous = core.buildThreads(
122
+ [
123
+ core.normalizeMessage(msg({ id: "r1", from: "e-a", subject: "T-05" })),
124
+ core.normalizeMessage(msg({ id: "r2", seq: 2, from: "e-z", subject: "T-05" })),
125
+ core.normalizeMessage(
126
+ msg({ id: "a1", from: "e-b", kind: "HELP_ACCEPT", inReplyTo: "m-vencido", subject: "T-05", createdAt: "2026-10-29T11:56:00.000Z" }),
127
+ ),
128
+ ],
129
+ { now: NOW },
130
+ );
131
+ assert.equal(ambiguous.unlinked.length, 1, "con dos hilos del mismo subject no se adivina");
132
+ assert.equal(ambiguous.unlinked[0].id, "a1");
133
+ });
134
+
135
+ test("máquina de estados pending -> accepted -> completed", () => {
136
+ const base = [msg({ id: "r1" })];
137
+ const pending = core.buildThreads(base, { now: NOW }).threads[0];
138
+ assert.equal(pending.status, "pending");
139
+ assert.equal(core.statusLabel(pending.status), "Esperando respuesta");
140
+
141
+ const accepted = core.buildThreads(
142
+ base.concat([msg({ id: "a1", from: "e-b", kind: "HELP_ACCEPT", inReplyTo: "r1", createdAt: "2026-10-29T11:56:00.000Z" })]),
143
+ { now: NOW },
144
+ ).threads[0];
145
+ assert.equal(accepted.status, "accepted", "aceptar no cierra el hilo");
146
+
147
+ const completed = core.buildThreads(
148
+ base.concat([
149
+ msg({ id: "a1", from: "e-b", kind: "HELP_ACCEPT", inReplyTo: "r1", createdAt: "2026-10-29T11:56:00.000Z" }),
150
+ msg({
151
+ id: "s1",
152
+ from: "e-a",
153
+ kind: "STATUS",
154
+ inReplyTo: "r1",
155
+ body: { summary: "resuelto con lo que me pasó e-b" },
156
+ createdAt: "2026-10-29T11:59:00.000Z",
157
+ }),
158
+ ]),
159
+ { now: NOW },
160
+ ).threads[0];
161
+ assert.equal(completed.status, "completed");
162
+ });
163
+
164
+ test("declined y partial tienen su propio estado; partial vencido completa", () => {
165
+ const declined = core.buildThreads(
166
+ [msg({ id: "r1" }), msg({ id: "d1", from: "e-b", kind: "HELP_DECLINE", inReplyTo: "r1", createdAt: "2026-10-29T11:56:00.000Z" })],
167
+ { now: NOW },
168
+ ).threads[0];
169
+ assert.equal(declined.status, "declined");
170
+
171
+ const partial = core.buildThreads(
172
+ [msg({ id: "r1" }), msg({ id: "p1", from: "e-b", kind: "HELP_PARTIAL", inReplyTo: "r1", createdAt: "2026-10-29T11:56:00.000Z" })],
173
+ { now: NOW },
174
+ ).threads[0];
175
+ assert.equal(partial.status, "partial");
176
+
177
+ const expiredPartial = core.buildThreads(
178
+ [
179
+ msg({ id: "r1", createdAt: "2026-10-29T10:00:00.000Z", ttlSec: 60 }),
180
+ msg({ id: "p1", from: "e-b", kind: "HELP_PARTIAL", inReplyTo: "r1", createdAt: "2026-10-29T10:00:30.000Z" }),
181
+ ],
182
+ { now: NOW },
183
+ ).threads[0];
184
+ assert.equal(expiredPartial.status, "completed", "TTL vencido con respuesta cierra el hilo");
185
+ assert.equal(expiredPartial.expired, true);
186
+ });
187
+
188
+ test("solo preguntas deja el hilo pendiente", () => {
189
+ const thread = core.buildThreads(
190
+ [msg({ id: "r1" }), msg({ id: "q1", from: "e-b", kind: "HELP_QUESTION", inReplyTo: "r1", createdAt: "2026-10-29T11:56:00.000Z" })],
191
+ { now: NOW },
192
+ ).threads[0];
193
+ assert.equal(thread.status, "pending");
194
+ assert.equal(thread.counts.question, 1);
195
+ });
196
+
197
+ test("hilos ordenados por actividad y conteo global por estado", () => {
198
+ const built = core.buildThreads(
199
+ [
200
+ msg({ id: "r1", seq: 1, from: "e-a", createdAt: "2026-10-29T11:50:00.000Z" }),
201
+ msg({ id: "r2", seq: 2, from: "e-b", createdAt: "2026-10-29T11:58:00.000Z" }),
202
+ msg({ id: "a1", from: "e-c", kind: "HELP_ACCEPT", inReplyTo: "r1", createdAt: "2026-10-29T11:59:00.000Z" }),
203
+ ],
204
+ { now: NOW },
205
+ );
206
+ assert.deepEqual(arr(built.threads).map((t) => t.id), ["r1", "r2"], "el más reciente arriba");
207
+ const counts = core.statusCounts(built.threads);
208
+ assert.equal(counts.all, 2);
209
+ assert.equal(counts.accepted, 1);
210
+ assert.equal(counts.pending, 1);
211
+ });
212
+
213
+ test("filtra por estado, agente y texto (con acentos)", () => {
214
+ const built = core.buildThreads(
215
+ [
216
+ msg({ id: "r1", from: "e-a", body: { need: "Revisión de accesibilidad" } }),
217
+ msg({ id: "a1", from: "e-b", kind: "HELP_ACCEPT", inReplyTo: "r1", subject: "T-05", createdAt: "2026-10-29T11:56:00.000Z" }),
218
+ msg({ id: "r2", seq: 2, from: "e-c", subject: "T-06", body: { need: "contratos de API" }, createdAt: "2026-10-29T11:50:00.000Z" }),
219
+ ],
220
+ { now: NOW },
221
+ );
222
+
223
+ assert.equal(core.filterThreads(built.threads, { status: "accepted" }).length, 1);
224
+ assert.equal(core.filterThreads(built.threads, { status: "pending" })[0].id, "r2");
225
+ assert.equal(core.filterThreads(built.threads, { agent: "e-b" })[0].id, "r1");
226
+ assert.equal(core.filterThreads(built.threads, { query: "revision" }).length, 1, "ignora acentos");
227
+ assert.equal(core.filterThreads(built.threads, { query: "contratos" })[0].id, "r2");
228
+ assert.equal(core.filterThreads(built.threads, { query: "nada-que-ver" }).length, 0);
229
+ assert.equal(core.filterThreads(built.threads, { onlyPending: true }).length, 1);
230
+ });
231
+
232
+ test("detecta mensajes nuevos y respeta hilos abiertos", () => {
233
+ const first = core.buildThreads([msg({ id: "r1" })], { now: NOW });
234
+ const seen = core.idsMap(first.messages);
235
+
236
+ const second = core.buildThreads(
237
+ [msg({ id: "r1" }), msg({ id: "a1", from: "e-b", kind: "HELP_ACCEPT", inReplyTo: "r1", createdAt: "2026-10-29T11:56:00.000Z" })],
238
+ { now: NOW },
239
+ );
240
+
241
+ const all = core.newMessages(seen, second.threads);
242
+ assert.equal(all.length, 1);
243
+ assert.equal(all[0].message.id, "a1");
244
+ assert.equal(all[0].threadId, "r1");
245
+ assert.equal(all[0].isRoot, false);
246
+
247
+ const closed = core.newMessages(seen, second.threads, { onlyOpen: true, openThreads: {} });
248
+ assert.equal(closed.length, 0, "un hilo colapsado no anuncia");
249
+
250
+ const opened = core.newMessages(seen, second.threads, { onlyOpen: true, openThreads: { r1: true } });
251
+ assert.equal(opened.length, 1, "un hilo abierto sí anuncia");
252
+
253
+ const announcement = core.announcementFor(opened[0]);
254
+ assert.match(announcement, /Acepta de e-b/);
255
+ });
256
+
257
+ test("tiempo relativo y reloj", () => {
258
+ assert.equal(core.relativeTime("2026-10-29T11:59:58.000Z", NOW), "ahora");
259
+ assert.equal(core.relativeTime("2026-10-29T11:59:30.000Z", NOW), "hace 30s");
260
+ assert.equal(core.relativeTime("2026-10-29T11:30:00.000Z", NOW), "hace 30m");
261
+ assert.equal(core.relativeTime("2026-10-29T06:00:00.000Z", NOW), "hace 6h");
262
+ assert.equal(core.relativeTime("2026-10-20T12:00:00.000Z", NOW), "hace 9d");
263
+ assert.equal(core.relativeTime("2026-10-29T12:00:10.000Z", NOW), "en 10s");
264
+ assert.equal(core.relativeTime("no-es-fecha", NOW), "—");
265
+ assert.match(core.formatClock("2026-10-29T11:59:00.000Z"), /^\d{2}:\d{2}:\d{2}$/);
266
+ assert.equal(core.formatClock("no-es-fecha"), "--:--:--");
267
+ });
268
+
269
+ test("avatares: iniciales estables y hue determinista", () => {
270
+ assert.equal(core.initials("e-testing-api-tester"), "TT");
271
+ assert.equal(core.initials("API Tester"), "AT");
272
+ assert.equal(core.initials("oficina"), "OF");
273
+ assert.equal(core.initials(""), "?");
274
+ assert.equal(core.agentHue("e-a"), core.agentHue("e-a"));
275
+ assert.ok(core.agentHue("e-a") >= 0 && core.agentHue("e-a") < 360);
276
+ assert.notEqual(core.agentHue("e-a"), core.agentHue("e-b"));
277
+ });
278
+
279
+ test("describeBody ordena claves conocidas y no pierde campos nuevos", () => {
280
+ const parts = core.describeBody({
281
+ need: "arreglar el gate",
282
+ why_me: "no tengo permisos",
283
+ zeta: "extra",
284
+ alpha: 3,
285
+ });
286
+ assert.deepEqual(arr(parts).map((p) => p.key), ["need", "why_me", "alpha", "zeta"]);
287
+ assert.equal(parts[0].label, "Necesita");
288
+ assert.equal(parts[2].text, "3");
289
+ assert.deepEqual(arr(core.describeBody(null)), []);
290
+ });
291
+
292
+ test("readSnapshot tolera formatos y reporta lo que descarta", () => {
293
+ const fromArray = core.readSnapshot([msg({ id: "r1" })]);
294
+ assert.equal(fromArray.messages.length, 1);
295
+ assert.equal(fromArray.warnings.length, 0);
296
+
297
+ const fromSingle = core.readSnapshot(msg({ id: "r1" }));
298
+ assert.equal(fromSingle.messages.length, 1);
299
+
300
+ const broken = core.readSnapshot({ messages: [msg({ id: "r1" }), { id: "malo" }] });
301
+ assert.equal(broken.messages.length, 1);
302
+ assert.equal(broken.warnings.length, 1);
303
+
304
+ const empty = core.readSnapshot(null);
305
+ assert.deepEqual(arr(empty.messages), []);
306
+ assert.equal(empty.warnings.length, 1);
307
+
308
+ const withMeta = core.readSnapshot({
309
+ messages: [msg({ id: "r1" }), msg({ id: "r1" })],
310
+ agents: { "e-a": { displayName: "A" } },
311
+ cards: { "T-05": { title: "x" } },
312
+ acks: { r1: [{ agentId: "e-b" }] },
313
+ generatedAt: "2026-10-29T12:00:00.000Z",
314
+ });
315
+ assert.equal(withMeta.messages.length, 1, "deduplica por id");
316
+ assert.equal(withMeta.agents["e-a"].displayName, "A");
317
+ assert.equal(withMeta.meta.generatedAt, "2026-10-29T12:00:00.000Z");
318
+ assert.deepEqual(arr(core.ackedBy(withMeta.acks, "r1")), ["e-b"]);
319
+ assert.deepEqual(arr(core.ackedBy(withMeta.acks, "nope")), []);
320
+ });
321
+
322
+ test("la firma del snapshot cambia sólo cuando cambia el contenido", () => {
323
+ const a = core.buildThreads([msg({ id: "r1" })], { now: NOW });
324
+ const b = core.buildThreads([msg({ id: "r1" })], { now: NOW + 5000 });
325
+ assert.equal(core.snapshotSignature(a.threads), core.snapshotSignature(b.threads));
326
+
327
+ const c = core.buildThreads(
328
+ [msg({ id: "r1" }), msg({ id: "a1", from: "e-b", kind: "HELP_ACCEPT", inReplyTo: "r1", createdAt: "2026-10-29T11:56:00.000Z" })],
329
+ { now: NOW },
330
+ );
331
+ assert.notEqual(core.snapshotSignature(a.threads), core.snapshotSignature(c.threads));
332
+ });
333
+
334
+ test("escapeHtml neutraliza inyección en bodies", () => {
335
+ assert.equal(core.escapeHtml('<img src=x onerror="alert(1)">'), "&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
336
+ });