agents-city 0.3.0-beta.21 → 0.3.0

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 (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +310 -70
  3. package/README.md +297 -69
  4. package/bin/agents-city.js +3 -0
  5. package/bin/doctor +3 -0
  6. package/bin/hall.html +164 -24
  7. package/bin/navegador.mjs +415 -0
  8. package/bin/serve.py +383 -127
  9. package/bin/shortcut +3 -0
  10. package/bin/test +5 -2
  11. package/bin/test-actualiza.py +130 -0
  12. package/bin/test-atajos.py +301 -0
  13. package/bin/test-busca.py +216 -0
  14. package/bin/test-cage.py +170 -2
  15. package/bin/test-card.py +2 -2
  16. package/bin/test-cities.py +45 -0
  17. package/bin/test-contracts.py +12 -5
  18. package/bin/test-doctor.py +33 -0
  19. package/bin/test-navegador.py +164 -0
  20. package/bin/test-seat.py +245 -25
  21. package/bin/test-serve.py +214 -9
  22. package/bin/test-workspace.py +63 -0
  23. package/bin/testlib.py +23 -0
  24. package/bin/update +3 -0
  25. package/city/web/dist/city.js +47 -47
  26. package/city/web/dist/index.html +1 -1
  27. package/city/web/dist-hall/hall.js +2193 -174
  28. package/city/web/src/bienvenida.ts +686 -0
  29. package/city/web/src/es.ts +180 -0
  30. package/city/web/src/hall.ts +520 -168
  31. package/city/web/src/idioma.ts +86 -0
  32. package/city/web/src/main.ts +27 -0
  33. package/city/web/src/motores.ts +54 -0
  34. package/docs/agents-first.md +8 -1
  35. package/docs/security.md +46 -12
  36. package/docs/testing.md +1 -1
  37. package/package.json +1 -1
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/channel/bus.js +1 -1
  40. package/plugin/channel/bus.ts +1 -1
  41. package/plugin/channel/runtime/codex.ts +1 -1
  42. package/plugin/channel/runtime-gateway.js +1 -1
  43. package/plugin/scripts/actualiza.py +198 -0
  44. package/plugin/scripts/atajos.py +506 -0
  45. package/plugin/scripts/busca.py +436 -0
  46. package/plugin/scripts/cage.py +266 -26
  47. package/plugin/scripts/capabilities.py +17 -10
  48. package/plugin/scripts/card.py +10 -0
  49. package/plugin/scripts/cities.py +34 -0
  50. package/plugin/scripts/city-session.sh +33 -7
  51. package/plugin/scripts/doctor.py +122 -0
  52. package/plugin/scripts/find-repos.sh +12 -105
  53. package/plugin/scripts/read-card.py +6 -2
  54. package/plugin/scripts/report.py +5 -6
  55. package/plugin/scripts/reset.py +50 -14
  56. package/plugin/scripts/seat.py +445 -103
  57. package/plugin/scripts/workspace.py +197 -0
@@ -0,0 +1,686 @@
1
+ /**
2
+ * First run: a city, built by answering five short questions.
3
+ *
4
+ * What was here before was a checklist on a page of statistics about a city
5
+ * that did not exist yet, and the Hall opened on a map with nothing to draw —
6
+ * "Not drawn yet" as a first impression. A person arriving for the first time
7
+ * does not need a dashboard. They need to be told, in their own words, what
8
+ * this thing is and what to do next.
9
+ *
10
+ * So: one question per screen, plain language, every jargon word explained the
11
+ * first time it appears, and nothing mandatory except the two decisions the
12
+ * product genuinely cannot invent — what kind of work happens here, and who
13
+ * does it. Everything is skippable and everything is re-editable afterwards
14
+ * from the same pages that own it, because this flow writes through the very
15
+ * same endpoints those pages use. It is a guide, not a second implementation.
16
+ */
17
+
18
+ import { plural, t as _ } from './idioma';
19
+ import { ESFUERZOS, MODELOS, RUNTIMES, opciones } from './motores';
20
+
21
+ export interface Dominio {
22
+ id: string;
23
+ name: string;
24
+ summary: string;
25
+ }
26
+ export interface Rol {
27
+ id: string;
28
+ name: string;
29
+ summary: string;
30
+ trade: string;
31
+ }
32
+ export interface RepoDelDisco {
33
+ nombre: string;
34
+ ruta: string;
35
+ /** repo · worktree · docs — a house can be given any of the three. */
36
+ clase: string;
37
+ cuando: string;
38
+ /** Touched recently, so almost certainly what this person came to pick. */
39
+ mio: boolean;
40
+ }
41
+
42
+ /** What the onboarding needs from the Hall. Passed in rather than imported, so
43
+ * this file cannot quietly grow its own way of talking to the server. */
44
+ export interface Puerta {
45
+ api: <T>(ruta: string, opts?: RequestInit) => Promise<T>;
46
+ esc: (s: unknown) => string;
47
+ aviso: (texto: string, malo?: boolean) => void;
48
+ refresca: () => Promise<void>;
49
+ /** Leave the guide and open a normal Hall section. */
50
+ vete: (seccion: string) => void;
51
+ ciudad: string;
52
+ yo: string;
53
+ datos: string;
54
+ }
55
+
56
+ interface AgenteNuevo {
57
+ nombre: string;
58
+ clase: string;
59
+ rol: string;
60
+ montajes: string[];
61
+ /** Which CLI runs it, and — on Claude — the engine and how hard it thinks.
62
+ * Empty means the owner's default, which is a real answer and the one most
63
+ * people should give. Asked here because it is a decision somebody has
64
+ * already made in their head while naming the house; making them find three
65
+ * dropdowns afterwards is making them say it twice. */
66
+ runtime: string;
67
+ modelo: string;
68
+ esfuerzo: string;
69
+ }
70
+
71
+ const CLASES: Array<[string, string, string]> = [
72
+ [
73
+ 'code',
74
+ 'It writes code',
75
+ 'Its house grows with the pull requests it merges. Mounts repositories and worktrees.',
76
+ ],
77
+ [
78
+ 'knowledge',
79
+ 'It keeps knowledge',
80
+ 'Its house grows with the documents it writes. Needs no git at all.',
81
+ ],
82
+ ['coordinator', 'It coordinates', 'Its house grows with the decisions it records.'],
83
+ ];
84
+
85
+ /** One glyph per kind of place, so a chip says what it is without a legend. */
86
+ const CLASE_CORTA: Record<string, string> = { repo: 'git', worktree: 'wt', docs: 'doc' };
87
+
88
+ const PASOS = ['Welcome', 'The work', 'Your chair', 'The houses', 'Ready'];
89
+
90
+ /** A house, drawn rather than shipped: the same isometric shape the map and the
91
+ * desktop icon use, so the first thing somebody sees is already the product. */
92
+ function casita(color: string): string {
93
+ return `<svg viewBox="0 0 120 108" role="img" aria-label="an isometric house" class="bvCasa">
94
+ <polygon points="60,10 112,40 60,70 8,40" fill="${color}" opacity=".95"/>
95
+ <polygon points="8,40 60,70 60,100 8,70" fill="${color}" opacity=".62"/>
96
+ <polygon points="112,40 60,70 60,100 112,70" fill="${color}" opacity=".40"/>
97
+ </svg>`;
98
+ }
99
+
100
+ export class Bienvenida {
101
+ private paso = 0;
102
+ private dominios: Dominio[] = [];
103
+ private roles: Rol[] = [];
104
+ private rolesDeAgente: Rol[] = [];
105
+ private repos: RepoDelDisco[] = [];
106
+ private dominio = '';
107
+ private rol = '';
108
+ private roster: AgenteNuevo[] = [];
109
+ private enCurso: AgenteNuevo | null = null;
110
+ private guardando = false;
111
+ private buscandoRepos = false;
112
+ private reposListos = false;
113
+ /** What is typed in the mount search box, kept across repaints. */
114
+ private filtro = '';
115
+ private lienzo: HTMLElement | null = null;
116
+
117
+ constructor(private readonly p: Puerta) {}
118
+
119
+ /** Draw the current step into `lienzo`. The only entry point. */
120
+ async pinta(lienzo: HTMLElement): Promise<void> {
121
+ this.lienzo = lienzo;
122
+ if (!this.dominios.length) {
123
+ lienzo.innerHTML = '<p class="cargando">reading the domain packs</p>';
124
+ const [d, ra] = await Promise.all([
125
+ this.p.api<{ domains: Dominio[] }>('/api/domains'),
126
+ this.p.api<{ roles: Rol[] }>('/api/roles?scope=agent'),
127
+ ]);
128
+ this.dominios = d.domains;
129
+ this.rolesDeAgente = ra.roles;
130
+ }
131
+ lienzo.innerHTML = `<div class="bv">${this.cabecera()}${await this.cuerpo()}</div>`;
132
+ this.enlaza(lienzo);
133
+ }
134
+
135
+ private cabecera(): string {
136
+ const puntos = PASOS.map(
137
+ (nombre, i) =>
138
+ `<span class="bvPunto ${i === this.paso ? 'aqui' : i < this.paso ? 'hecho' : ''}"
139
+ title="${this.p.esc(_(nombre))}"><i></i>${this.p.esc(_(nombre))}</span>`,
140
+ ).join('');
141
+ return `<div class="bvPasos">${puntos}</div>`;
142
+ }
143
+
144
+ private async cuerpo(): Promise<string> {
145
+ switch (this.paso) {
146
+ case 0:
147
+ return this.pantallaHola();
148
+ case 1:
149
+ return this.pantallaDominio();
150
+ case 2:
151
+ return await this.pantallaRol();
152
+ case 3:
153
+ return this.pantallaAgentes();
154
+ default:
155
+ return this.pantallaFinal();
156
+ }
157
+ }
158
+
159
+ // ── 0. what this is ────────────────────────────────────────────────────────
160
+ private pantallaHola(): string {
161
+ return `
162
+ <div class="bvHola">
163
+ <div class="bvArte">${casita('#7048e8')}${casita('#45b9a7')}${casita('#f2674a')}</div>
164
+ <div>
165
+ <span class="sub">welcome</span>
166
+ <h1>${_('A city is you, and the houses around you')}</h1>
167
+ <p class="prosa">${_(`You take the chair. Around it, one <b>house</b> per worker — an
168
+ <b>agent</b> — each with its own window, its own role and its own corner of your
169
+ disk: a repository, three of them, a folder of documents, whatever it actually
170
+ works on. The map draws them as houses that grow with the work done in them, and
171
+ many houses are a city. They never talk to each other behind your back: you chair,
172
+ they answer.`)}</p>
173
+ <p class="prosa">${_('Everything lives as plain files in {donde}. Nothing leaves this machine, there is no account, and you can edit any of it by hand afterwards.', { donde: `<code class="mono">${this.p.esc(this.p.datos)}</code>` })}</p>
174
+ <p class="prosa">${_('<b>Four questions and you are working.</b> You can skip any of them and change everything later.')}</p>
175
+ <div class="bvBotones">
176
+ <button class="bt ppal" data-bv="siguiente">${_('Let’s build it')}</button>
177
+ <button class="bt" data-bv="salir">${_('I’ll set it up myself')}</button>
178
+ </div>
179
+ </div>
180
+ </div>`;
181
+ }
182
+
183
+ // ── 1. the domain ─────────────────────────────────────────────────────────
184
+ private pantallaDominio(): string {
185
+ const tarjetas = this.dominios
186
+ .map(
187
+ (d) => `
188
+ <button class="bvOpcion ${this.dominio === d.id ? 'elegida' : ''}" data-bv="dominio"
189
+ data-id="${this.p.esc(d.id)}">
190
+ <b>${this.p.esc(d.name)}</b><span>${this.p.esc(d.summary)}</span></button>`,
191
+ )
192
+ .join('');
193
+ return `
194
+ <div class="bvPaso">
195
+ <span class="sub">${_('question 1 of 4')}</span>
196
+ <h1>${_('What kind of work happens here?')}</h1>
197
+ <p class="prosa">${_(`It decides the vocabulary, the roles you will be offered and what
198
+ counts as evidence in a decision. A clinic does not ship pull requests, and a law
199
+ firm does not measure story points. Pick the closest one — you can change it later.`)}</p>
200
+ <div class="bvRejilla">${tarjetas}</div>
201
+ <div class="bvBotones">
202
+ <button class="bt" data-bv="atras">${_('Back')}</button>
203
+ <button class="bt ppal" data-bv="siguiente" ${this.dominio ? '' : 'disabled'}>${_('Next')}</button>
204
+ </div>
205
+ </div>`;
206
+ }
207
+
208
+ // ── 2. your chair ─────────────────────────────────────────────────────────
209
+ private async pantallaRol(): Promise<string> {
210
+ if (!this.roles.length || this.roles[0]?.id === undefined) {
211
+ const r = await this.p.api<{ roles: Rol[] }>(
212
+ '/api/roles?domain=' + encodeURIComponent(this.dominio),
213
+ );
214
+ this.roles = r.roles;
215
+ }
216
+ const tarjetas = this.roles
217
+ .map(
218
+ (r) => `
219
+ <button class="bvOpcion ${this.rol === r.id ? 'elegida' : ''}" data-bv="rol"
220
+ data-id="${this.p.esc(r.id)}">
221
+ <b>${this.p.esc(r.name)}</b><span>${this.p.esc(r.summary || r.trade)}</span></button>`,
222
+ )
223
+ .join('');
224
+ return `
225
+ <div class="bvPaso">
226
+ <span class="sub">${_('question 2 of 4')}</span>
227
+ <h1>${_('And what are you, here?')}</h1>
228
+ <p class="prosa">${_(`You chair this city whatever you answer — this is your
229
+ <b>speciality</b>, not your authority. It shapes the perspective you bring to a
230
+ decision and the knowledge files the city writes for you. <b>Blank</b> is a real
231
+ answer: it means no preset knowledge.`)}</p>
232
+ <div class="bvRejilla">${tarjetas}</div>
233
+ <div class="bvBotones">
234
+ <button class="bt" data-bv="atras">${_('Back')}</button>
235
+ <button class="bt ppal" data-bv="siguiente" ${this.rol ? '' : 'disabled'}>Next</button>
236
+ </div>
237
+ </div>`;
238
+ }
239
+
240
+ // ── 3. the agents ─────────────────────────────────────────────────────────
241
+ private pantallaAgentes(): string {
242
+ // The repository index is a disk scan — a minute on a big home the first
243
+ // time. Awaiting it here froze the guide on the PREVIOUS screen with no
244
+ // explanation: the person clicks Next and nothing happens. So the step
245
+ // draws immediately and the list arrives when it arrives.
246
+ this.buscaReposUnaVez();
247
+ if (!this.enCurso) return this.listaDeAgentes();
248
+ return this.formularioDeAgente(this.enCurso);
249
+ }
250
+
251
+ private buscaReposUnaVez(otraVez = false): void {
252
+ if (this.buscandoRepos || (this.reposListos && !otraVez)) return;
253
+ this.buscandoRepos = true;
254
+ void this.p
255
+ .api<{ repos: RepoDelDisco[] }>(
256
+ '/api/misrepos?user=' + encodeURIComponent(this.p.yo) + (otraVez ? '&refresh=1' : ''),
257
+ )
258
+ .then((r) => {
259
+ // The whole index is kept, not a slice of it: the search box below is
260
+ // what makes a thousand places usable, and a list cut at two hundred
261
+ // would quietly fail to find the one somebody typed the name of.
262
+ this.repos = r.repos ?? [];
263
+ })
264
+ .catch(() => {
265
+ this.repos = []; // no index is not a reason to block the flow
266
+ })
267
+ .finally(() => {
268
+ this.buscandoRepos = false;
269
+ this.reposListos = true;
270
+ // Only repaint if this step is still on screen.
271
+ if (this.lienzo && this.paso === 3) void this.pinta(this.lienzo);
272
+ });
273
+ }
274
+
275
+ private listaDeAgentes(): string {
276
+ const fichas = this.roster
277
+ .map(
278
+ (a) => `
279
+ <div class="bvFicha">
280
+ <b>${this.p.esc(a.nombre)}</b>
281
+ <span>${this.p.esc(a.clase)} · ${this.p.esc(a.rol)}</span>
282
+ <span class="de">${
283
+ a.montajes.length
284
+ ? this.p.esc(a.montajes.map((m) => m.split('/').pop()).join(', '))
285
+ : _('nothing mounted yet')
286
+ }</span>
287
+ </div>`,
288
+ )
289
+ .join('');
290
+ return `
291
+ <div class="bvPaso">
292
+ <span class="sub">${_('question 3 of 4')}</span>
293
+ <h1>${_(this.roster.length ? 'Who else lives here?' : 'Who lives in your city?')}</h1>
294
+ <p class="prosa">${_(`Every house holds one worker — an <b>agent</b> — with its own window,
295
+ its own role and its own corner of your disk. A house is <b>not</b> a repository: one
296
+ can answer for three services and a folder of documents at once, and a house whose
297
+ work is documents needs no git anywhere.`)}</p>
298
+ ${this.roster.length ? `<div class="bvFichas">${fichas}</div>` : ''}
299
+ <div class="bvBotones">
300
+ <button class="bt ppal" data-bv="nuevo">${_(
301
+ this.roster.length ? 'Add another house' : 'Build the first house',
302
+ )}</button>
303
+ <button class="bt" data-bv="siguiente">${_(
304
+ this.roster.length ? 'That is everyone' : 'Skip — just me for now',
305
+ )}</button>
306
+ </div>
307
+ </div>`;
308
+ }
309
+
310
+ /**
311
+ * The places on this disk worth offering for this house, ranked.
312
+ *
313
+ * With nothing typed it is what you touched recently, because on a machine
314
+ * with three hundred clones a list is not an answer. What is typed matches
315
+ * the name and the path both — people look for `rankia` and for `Documents`
316
+ * with equal frequency, and only one of those is a repository name. A house
317
+ * that keeps knowledge sees document folders first, because that is the one
318
+ * kind of agent the old picker could not help at all.
319
+ */
320
+ private buscaEnElDisco(a: AgenteNuevo): RepoDelDisco[] {
321
+ const q = this.filtro.trim().toLowerCase();
322
+ const quiereDocs = a.clase === 'knowledge';
323
+ const puntua = (r: RepoDelDisco): number =>
324
+ (r.clase === 'docs' ? (quiereDocs ? -2 : 1) : 0) + (r.mio ? -1 : 0);
325
+ const encaja = q
326
+ ? this.repos.filter(
327
+ (r) => r.nombre.toLowerCase().includes(q) || r.ruta.toLowerCase().includes(q),
328
+ )
329
+ : this.repos.filter((r) => r.mio);
330
+ // A mount already chosen stays visible even when the search moves past it,
331
+ // so unticking never means hunting for it again.
332
+ const elegidas = this.repos.filter((r) => a.montajes.includes(r.ruta) && !encaja.includes(r));
333
+ return elegidas
334
+ .concat(encaja)
335
+ .sort((x, y) => puntua(x) - puntua(y))
336
+ .slice(0, 40);
337
+ }
338
+
339
+ private formularioDeAgente(a: AgenteNuevo): string {
340
+ const clases = CLASES.map(
341
+ ([id, titulo, porque]) => `
342
+ <button class="bvOpcion ${a.clase === id ? 'elegida' : ''}" data-bv="clase" data-id="${id}">
343
+ <b>${_(titulo)}</b><span>${_(porque)}</span></button>`,
344
+ ).join('');
345
+ const roles = this.rolesDeAgente
346
+ .map(
347
+ (r) =>
348
+ `<option value="${this.p.esc(r.id)}"${r.id === a.rol ? ' selected' : ''}>${this.p.esc(
349
+ r.name,
350
+ )}</option>`,
351
+ )
352
+ .join('');
353
+ const esClaude = (a.runtime || 'claude') === 'claude';
354
+ const encontrados = this.buscaEnElDisco(a);
355
+ const sugeridos = encontrados
356
+ .map(
357
+ (r) => `<button class="bvChip cl-${this.p.esc(r.clase)} ${
358
+ a.montajes.includes(r.ruta) ? 'elegida' : ''
359
+ }"
360
+ data-bv="monta" data-ruta="${this.p.esc(r.ruta)}"
361
+ title="${this.p.esc(r.ruta)}${r.cuando ? ` · ${this.p.esc(r.cuando)}` : ''}"
362
+ ><i class="bvClase">${this.p.esc(CLASE_CORTA[r.clase] ?? r.clase)}</i>${this.p.esc(
363
+ r.nombre,
364
+ )}</button>`,
365
+ )
366
+ .join('');
367
+ return `
368
+ <div class="bvPaso">
369
+ <span class="sub">${_('a new house')}</span>
370
+ <h1>${_('Who lives in it?')}</h1>
371
+ <div class="campo"><label>${_('What do you call it?')}</label>
372
+ <input type="text" id="bvNombre" value="${this.p.esc(a.nombre)}"
373
+ placeholder="${_('urgencias, api, the handbook — whatever you would say out loud')}"></div>
374
+ <label class="bvEtiqueta">${_('What kind of work does it do?')}</label>
375
+ <div class="bvRejilla bvTres">${clases}</div>
376
+ <div class="campo"><label>${_('Its role — its speciality, never authority')}</label>
377
+ <select id="bvRol">${roles}</select></div>
378
+ <label class="bvEtiqueta">${_('What runs it?')}</label>
379
+ <p class="pista">${_(
380
+ 'Leave all three on default and it runs the way you do. Model and effort are Claude’s to read; another CLI carries its own flags.',
381
+ )}</p>
382
+ <div class="bvMotor">
383
+ <div class="campo"><label>${_('provider')}</label>
384
+ <select id="bvRuntime">${opciones(RUNTIMES, a.runtime, this.p.esc, 'claude')}</select></div>
385
+ <div class="campo"><label>${_('engine')}</label>
386
+ <select id="bvModelo" ${esClaude ? '' : 'disabled'}>${opciones(
387
+ [''].concat(MODELOS),
388
+ a.modelo,
389
+ this.p.esc,
390
+ _('default'),
391
+ )}</select></div>
392
+ <div class="campo"><label>${_('effort')}</label>
393
+ <select id="bvEsfuerzo" ${esClaude ? '' : 'disabled'}>${opciones(
394
+ [''].concat(ESFUERZOS),
395
+ a.esfuerzo,
396
+ this.p.esc,
397
+ _('default'),
398
+ )}</select></div>
399
+ </div>
400
+ <label class="bvEtiqueta">${_('What does it work on?')}</label>
401
+ <p class="pista">${_(`Everything found on this disk: <b>repositories</b>, the
402
+ <b>worktrees</b> an isolated agent works in, and <b>folders of documents</b> with no git
403
+ at all. Pick as many as you like — this is the part that makes it an agent and not a
404
+ repo.`)}</p>
405
+ <div class="bvBuscador">
406
+ <input type="search" id="bvBusca" value="${this.p.esc(this.filtro)}"
407
+ placeholder="${_('search your disk — a name, or part of a path')}">
408
+ <button class="bt bvMini" data-bv="rebusca" ${this.buscandoRepos ? 'disabled' : ''}
409
+ title="${_('Look through the disk again — for something cloned a minute ago')}">${_(
410
+ this.buscandoRepos ? 'searching…' : 'Search again',
411
+ )}</button>
412
+ </div>
413
+ ${
414
+ sugeridos
415
+ ? `<div class="bvChips">${sugeridos}</div>
416
+ <p class="pista">${
417
+ this.filtro
418
+ ? _('{shown} of {total} places match', {
419
+ shown: String(encontrados.length),
420
+ total: String(this.repos.length),
421
+ })
422
+ : _('{total} places found — type to narrow it down', {
423
+ total: String(this.repos.length),
424
+ })
425
+ }</p>`
426
+ : this.buscandoRepos
427
+ ? `<p class="cargando">${_('looking through your disk…')}</p>`
428
+ : this.filtro
429
+ ? `<p class="pista">${_('Nothing matches that. Add it by path below.')}</p>`
430
+ : `<p class="pista">${_('Nothing found on this disk — add folders by path below.')}</p>`
431
+ }
432
+ <div class="campo"><label>${_('…or a path, and Enter')}</label>
433
+ <input type="text" id="bvRuta" placeholder="~/documents/handbook"></div>
434
+ ${
435
+ a.montajes.length
436
+ ? `<p class="pista">${_('Working on: {que}', { que: `<b>${this.p.esc(a.montajes.join(', '))}</b>` })}</p>`
437
+ : ''
438
+ }
439
+ <div class="bvBotones">
440
+ <button class="bt" data-bv="cancela">${_('Cancel')}</button>
441
+ <button class="bt ppal" data-bv="guarda" ${this.guardando ? 'disabled' : ''}>${_(
442
+ this.guardando ? 'building…' : 'Build it',
443
+ )}</button>
444
+ </div>
445
+ </div>`;
446
+ }
447
+
448
+ // ── 4. done ───────────────────────────────────────────────────────────────
449
+ private pantallaFinal(): string {
450
+ return `
451
+ <div class="bvPaso">
452
+ <span class="sub">${_('Ready').toLowerCase()}</span>
453
+ <h1>${_('{city} is alive', { city: this.p.esc(this.p.ciudad) })}</h1>
454
+ <p class="prosa">${_(
455
+ 'Your seat is written and {cuantas}. Everything you just answered is a plain file you can read and edit.',
456
+ {
457
+ cuantas: this.roster.length
458
+ ? plural(
459
+ this.roster.length,
460
+ '<b>{n}</b> house stands around it',
461
+ '<b>{n}</b> houses stand around it',
462
+ )
463
+ : _('the city is waiting for its first house'),
464
+ },
465
+ )}</p>
466
+ <p class="prosa">${_('What people usually do next:')}</p>
467
+ <div class="bvSiguientes">
468
+ <button class="bt ppal" data-bv="sesion">${_('Open my session')}</button>
469
+ <button class="bt" data-bv="ir-gente">${_('See the houses')}</button>
470
+ <button class="bt" data-bv="ir-mapa">${_('Draw the map')}</button>
471
+ <button class="bt" data-bv="ir-puesto">${_('Set a goal')}</button>
472
+ </div>
473
+ <p class="pista">${_(`A goal is optional, and the city works without one — but a round with
474
+ no goal is a status report, because there is nothing to argue against.`)}</p>
475
+ </div>`;
476
+ }
477
+
478
+ // ── wiring ────────────────────────────────────────────────────────────────
479
+ private enlaza(lienzo: HTMLElement): void {
480
+ const repinta = (): void => void this.pinta(lienzo);
481
+ lienzo.querySelectorAll<HTMLElement>('[data-bv]').forEach((el) => {
482
+ el.onclick = async (evento) => {
483
+ evento.preventDefault();
484
+ const que = el.dataset.bv;
485
+ if (que === 'salir') return this.p.vete('resumen');
486
+ if (que === 'atras') {
487
+ this.paso = Math.max(0, this.paso - 1);
488
+ return repinta();
489
+ }
490
+ if (que === 'dominio') {
491
+ this.dominio = el.dataset.id ?? '';
492
+ this.roles = [];
493
+ this.rol = '';
494
+ return repinta();
495
+ }
496
+ if (que === 'rol') {
497
+ this.rol = el.dataset.id ?? '';
498
+ return repinta();
499
+ }
500
+ if (que === 'nuevo') {
501
+ this.enCurso = {
502
+ nombre: '',
503
+ clase: 'code',
504
+ rol: 'blank',
505
+ montajes: [],
506
+ runtime: '',
507
+ modelo: '',
508
+ esfuerzo: '',
509
+ };
510
+ return repinta();
511
+ }
512
+ if (que === 'clase' && this.enCurso) {
513
+ this.enCurso.clase = el.dataset.id ?? 'code';
514
+ this.recogeFormulario(lienzo);
515
+ return repinta();
516
+ }
517
+ if (que === 'monta' && this.enCurso) {
518
+ const ruta = el.dataset.ruta ?? '';
519
+ const ya = this.enCurso.montajes.indexOf(ruta);
520
+ if (ya >= 0) this.enCurso.montajes.splice(ya, 1);
521
+ else this.enCurso.montajes.push(ruta);
522
+ this.recogeFormulario(lienzo);
523
+ return repinta();
524
+ }
525
+ if (que === 'rebusca') {
526
+ this.recogeFormulario(lienzo);
527
+ this.buscaReposUnaVez(true);
528
+ return repinta();
529
+ }
530
+ if (que === 'cancela') {
531
+ this.enCurso = null;
532
+ return repinta();
533
+ }
534
+ if (que === 'guarda') return void (await this.guardaAgente(lienzo));
535
+ if (que === 'siguiente') return void (await this.avanza(lienzo));
536
+ if (que === 'sesion') return void (await this.abreSesion());
537
+ if (que === 'ir-gente') return this.p.vete('gente');
538
+ if (que === 'ir-mapa') return this.p.vete('mapa');
539
+ if (que === 'ir-puesto') return this.p.vete('puesto');
540
+ };
541
+ });
542
+ // The search filters what is already in memory, so it repaints as you type
543
+ // without asking the disk anything. Focus and caret are restored because a
544
+ // box that loses the cursor on the third letter is not a search box.
545
+ const busca = lienzo.querySelector<HTMLInputElement>('#bvBusca');
546
+ if (busca) {
547
+ busca.oninput = () => {
548
+ this.filtro = busca.value;
549
+ const donde = busca.selectionStart ?? busca.value.length;
550
+ void this.pinta(lienzo).then(() => {
551
+ const otra = lienzo.querySelector<HTMLInputElement>('#bvBusca');
552
+ if (!otra) return;
553
+ otra.focus();
554
+ otra.setSelectionRange(donde, donde);
555
+ });
556
+ };
557
+ }
558
+ const ruta = lienzo.querySelector<HTMLInputElement>('#bvRuta');
559
+ if (ruta)
560
+ ruta.onkeydown = (e) => {
561
+ if (e.key !== 'Enter' || !this.enCurso) return;
562
+ e.preventDefault();
563
+ const valor = ruta.value.trim();
564
+ if (valor) this.enCurso.montajes.push(valor);
565
+ this.recogeFormulario(lienzo);
566
+ void this.pinta(lienzo);
567
+ };
568
+ lienzo.querySelector<HTMLSelectElement>('#bvRuntime')?.addEventListener('change', () => {
569
+ this.recogeFormulario(lienzo);
570
+ void this.pinta(lienzo);
571
+ });
572
+ if (!busca?.value) lienzo.querySelector<HTMLInputElement>('#bvNombre')?.focus();
573
+ }
574
+
575
+ /** Keep what is typed when a click forces a repaint. */
576
+ private recogeFormulario(lienzo: HTMLElement): void {
577
+ if (!this.enCurso) return;
578
+ const nombre = lienzo.querySelector<HTMLInputElement>('#bvNombre');
579
+ const rol = lienzo.querySelector<HTMLSelectElement>('#bvRol');
580
+ if (nombre) this.enCurso.nombre = nombre.value;
581
+ if (rol) this.enCurso.rol = rol.value;
582
+ for (const [id, campo] of [
583
+ ['#bvRuntime', 'runtime'],
584
+ ['#bvModelo', 'modelo'],
585
+ ['#bvEsfuerzo', 'esfuerzo'],
586
+ ] as const) {
587
+ const sel = lienzo.querySelector<HTMLSelectElement>(id);
588
+ if (sel) this.enCurso[campo] = sel.value;
589
+ }
590
+ const busca = lienzo.querySelector<HTMLInputElement>('#bvBusca');
591
+ if (busca) this.filtro = busca.value;
592
+ }
593
+
594
+ private async guardaAgente(lienzo: HTMLElement): Promise<void> {
595
+ this.recogeFormulario(lienzo);
596
+ const a = this.enCurso;
597
+ if (!a) return;
598
+ if (!a.nombre.trim()) {
599
+ this.p.aviso('Give it a name — it is how you will call it in its window', true);
600
+ return;
601
+ }
602
+ this.guardando = true;
603
+ await this.pinta(lienzo);
604
+ try {
605
+ // The seat has to exist before an agent can join it: agents live on the
606
+ // owner's card. Writing it here is why this step can come before the goal.
607
+ await this.aseguraFicha();
608
+ const r = await this.p.api<{ ok?: boolean; agent?: string; error?: string }>('/api/agentes', {
609
+ method: 'POST',
610
+ body: JSON.stringify({ name: a.nombre.trim(), kind: a.clase, role: a.rol }),
611
+ });
612
+ if (!r.ok || !r.agent) {
613
+ this.p.aviso(r.error || 'Could not add that agent', true);
614
+ return;
615
+ }
616
+ for (const ruta of a.montajes) {
617
+ const m = await this.p.api<{ ok?: boolean; error?: string }>('/api/montaje', {
618
+ method: 'POST',
619
+ body: JSON.stringify({ agent: r.agent, add: ruta }),
620
+ });
621
+ if (!m.ok) this.p.aviso(`${ruta}: ${m.error ?? 'could not mount'}`, true);
622
+ }
623
+ // The engine is a second write by necessity: `/api/agentes` owns who is
624
+ // on the roster and `/api/agente` owns how one is run, and neither is
625
+ // allowed to learn the other's job just to save a round trip.
626
+ const motor: Record<string, string> = {};
627
+ if (a.runtime) motor.runtime = a.runtime;
628
+ if (a.modelo) motor.model = a.modelo;
629
+ if (a.esfuerzo) motor.effort = a.esfuerzo;
630
+ if (Object.keys(motor).length) {
631
+ const m = await this.p.api<{ ok?: boolean; error?: string }>('/api/agente', {
632
+ method: 'POST',
633
+ body: JSON.stringify({ agent: r.agent, ...motor }),
634
+ });
635
+ if (!m.ok) this.p.aviso(m.error ?? 'Could not set its engine', true);
636
+ }
637
+ this.roster.push({ ...a, nombre: a.nombre.trim() });
638
+ this.enCurso = null;
639
+ this.p.aviso(`${a.nombre.trim()} joined the city`);
640
+ await this.p.refresca();
641
+ } catch (e) {
642
+ this.p.aviso(String(e), true);
643
+ } finally {
644
+ this.guardando = false;
645
+ await this.pinta(lienzo);
646
+ }
647
+ }
648
+
649
+ private async aseguraFicha(): Promise<void> {
650
+ await this.p.api<{ ok?: boolean; error?: string }>('/api/ficha', {
651
+ method: 'POST',
652
+ body: JSON.stringify({ domain: this.dominio || 'software', role: this.rol || 'blank' }),
653
+ });
654
+ }
655
+
656
+ private async avanza(lienzo: HTMLElement): Promise<void> {
657
+ // The step moves FIRST, and only then does anything touch the server.
658
+ // Refreshing the Hall repaints this view, and a repaint that lands after a
659
+ // slow write would otherwise redraw the step the person just left — the
660
+ // guide silently bouncing back one screen.
661
+ const escribeLaFicha = this.paso === 2;
662
+ this.paso = Math.min(PASOS.length - 1, this.paso + 1);
663
+ await this.pinta(lienzo);
664
+ if (escribeLaFicha) {
665
+ // The chair is written the moment it is answered, so leaving halfway
666
+ // still leaves a usable city behind rather than nothing.
667
+ await this.aseguraFicha();
668
+ await this.p.refresca();
669
+ }
670
+ }
671
+
672
+ private async abreSesion(): Promise<void> {
673
+ try {
674
+ const r = await this.p.api<{ ok?: boolean; attach?: string; error?: string }>('/api/sesion', {
675
+ method: 'POST',
676
+ body: JSON.stringify({ user: this.p.yo }),
677
+ });
678
+ this.p.aviso(
679
+ r.ok && r.attach ? `Session built — attach with: ${r.attach}` : (r.error ?? 'Could not'),
680
+ !r.ok,
681
+ );
682
+ } catch (e) {
683
+ this.p.aviso(String(e), true);
684
+ }
685
+ }
686
+ }