agents-city 0.1.0 → 0.2.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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "city",
11
11
  "source": "./plugin",
12
- "version": "0.1.0",
12
+ "version": "0.2.1",
13
13
  "description": "Operate one autonomous city seat, its domain, role, repo support agents, goal, recognised skills and explicit roads to other cities."
14
14
  }
15
15
  ]
package/README.es.md CHANGED
@@ -1058,13 +1058,22 @@ demuestra por sí solo mayor calidad de respuesta ni una afirmación SOTA.
1058
1058
 
1059
1059
  ### `agents-city reset`
1060
1060
 
1061
- Reinicia **una** ciudad gestionada conservando su identidad estable y sus repos.
1061
+ Reinicia ciudades gestionadas conservando su identidad estable y sus repos.
1062
1062
 
1063
1063
  ```bash
1064
- agents-city reset producto --dry-run
1065
- agents-city reset producto
1064
+ agents-city reset product --dry-run # muestra cada efecto, no cambia nada
1065
+ agents-city reset product
1066
+ agents-city reset product cliente-a # varias, separadas por espacios
1067
+ agents-city reset all # todas las de este propietario
1066
1068
  ```
1067
1069
 
1070
+ Un nombre desconocido aborta la ejecución **entera** antes de tocar nada:
1071
+ reiniciar tres ciudades y pararse en una errata es el peor final posible para un
1072
+ comando destructivo. El Hall tiene lo mismo como botón, en **Cities** — primero
1073
+ enseña qué desaparece, qué sobrevive y dónde queda la copia, y te pide escribir
1074
+ el nombre de la ciudad.
1075
+
1076
+
1068
1077
  El plan de reset:
1069
1078
 
1070
1079
  1. valida que el destino sea una ciudad gestionada, no una ruta arbitraria;
package/README.md CHANGED
@@ -1045,13 +1045,21 @@ benchmark; by itself it proves neither higher answer quality nor a SOTA claim.
1045
1045
 
1046
1046
  ### `agents-city reset`
1047
1047
 
1048
- Resets **one** managed city while preserving its stable identity and repositories.
1048
+ Resets managed cities while preserving their stable identity and repositories.
1049
1049
 
1050
1050
  ```bash
1051
- agents-city reset product --dry-run
1051
+ agents-city reset product --dry-run # show every effect, change nothing
1052
1052
  agents-city reset product
1053
+ agents-city reset product client-a # several, space separated
1054
+ agents-city reset all # every city this owner has
1053
1055
  ```
1054
1056
 
1057
+ One unknown name aborts the **whole** run before anything is touched: resetting
1058
+ three cities and then stopping on a typo is the worst outcome a destructive
1059
+ command can have. The Hall has the same thing as a button, in **Cities** — it
1060
+ first shows what disappears, what survives and where the copy lands, and asks
1061
+ you to type the city's name.
1062
+
1055
1063
  The reset plan:
1056
1064
 
1057
1065
  1. verifies the target is a managed city, not an arbitrary path;
package/bin/hall.html CHANGED
@@ -11,69 +11,60 @@
11
11
  <style>
12
12
  /* The map's palette and its three typefaces. The hall manages the city the map
13
13
  draws; they must not look like two products. Single visual world on purpose. */
14
- /* Two skins, one set of names. Night is the city's own light an isometric map
15
- at 3am — and stays the default. Day borrows the palette of agentscity.net, so
16
- the local tool and the public site are visibly the same product: its paper,
17
- its ink, its violet. Nothing below this block names a colour directly. */
14
+ /* Two skins, one set of names. DAY is the defaultthe palette of
15
+ agentscity.net, so the local tool and the public site are visibly one
16
+ product. Night is the map's own light, chosen with the switch and
17
+ remembered. Nothing below this block names a colour directly. */
18
18
  :root{
19
- /* Night, the city's own light: an isometric map at 3am. The default. */
20
- --suelo:#131a24; --suelo2:#1b2431; --suelo3:#222d3c; --linea:#26313f; --linea2:#38495f;
21
- --tinta:#e8e2d4; --tinta2:#9aa7b8; --tinta3:#6b7889;
22
- --lampara:#f5c451; --carta:#e2604f; --verde:#4ec98a;
23
- --panel:#101720; --burbuja:#171f2a; --burbuja-mia:#27291f;
24
- --borde-acento:#65552d; --sobre-lampara:#1a1408; --lampara-viva:#ffd469;
25
- --chip-luz:36% 30%; --chip-tinta:#f3eee4;
26
- --tono-palabra:#aa93ff; --tono-evidencia:#61ace9; --tono-verificacion:#50ccb1;
19
+ /* Day is the default: the palette of agentscity.net, so the tool on your
20
+ machine and the public site are one product from the first second. Night
21
+ is one click away and remembered — it is the map's own light, not the
22
+ app's. */
23
+ --suelo:#f3f0e8; --suelo2:#fbfaf6; --suelo3:#ffffff;
24
+ --linea:#e2ddd0; --linea2:#cdc6b6;
25
+ --tinta:#1d2229; --tinta2:#515861; --tinta3:#7b8187;
26
+ --lampara:#7048e8; --carta:#dd5d3e; --verde:#5f8f2b;
27
+ --panel:#fbfaf6; --burbuja:#ffffff; --burbuja-mia:#f4efff;
28
+ --borde-acento:#c9b8ff; --sobre-lampara:#ffffff; --lampara-viva:#5331bd;
29
+ --chip-luz:52% 86%; --chip-tinta:#1d2229;
30
+ --tono-palabra:#6b46f0; --tono-evidencia:#2b7ab8; --tono-verificacion:#1c8d78;
27
31
  --hud:"Chakra Petch",system-ui,sans-serif; --texto:"Newsreader",Georgia,serif;
28
32
  --mono:"IBM Plex Mono",ui-monospace,monospace;
29
- --sombra:none;
33
+ --sombra:0 18px 40px -30px #1d222945;
30
34
 
31
- /* Day, declared once: the tokens of agentscity.net its paper, its ink, its
32
- violet so the tool on your machine and the public site are one product.
33
- The two rules below only MAP these; a palette written twice drifts twice. */
34
- --dia-suelo:#f3f0e8; --dia-suelo2:#fbfaf6; --dia-suelo3:#ffffff;
35
- --dia-linea:#e2ddd0; --dia-linea2:#cdc6b6;
36
- --dia-tinta:#1d2229; --dia-tinta2:#515861; --dia-tinta3:#7b8187;
37
- --dia-lampara:#7048e8; --dia-carta:#dd5d3e; --dia-verde:#5f8f2b;
38
- --dia-panel:#fbfaf6; --dia-burbuja:#ffffff; --dia-burbuja-mia:#f4efff;
39
- --dia-borde-acento:#c9b8ff; --dia-sobre-lampara:#ffffff; --dia-lampara-viva:#5331bd;
40
- --dia-chip-luz:52% 86%; --dia-chip-tinta:#1d2229;
41
- --dia-tono-palabra:#6b46f0; --dia-tono-evidencia:#2b7ab8;
42
- --dia-tono-verificacion:#1c8d78;
43
- --dia-sombra:0 18px 40px -30px #1d222945;
35
+ /* Night, declared once: the city's own light, an isometric map at 3am. The
36
+ two rules below only MAP these; a palette written twice drifts twice. */
37
+ --noche-suelo:#131a24; --noche-suelo2:#1b2431; --noche-suelo3:#222d3c;
38
+ --noche-linea:#26313f; --noche-linea2:#38495f;
39
+ --noche-tinta:#e8e2d4; --noche-tinta2:#9aa7b8; --noche-tinta3:#6b7889;
40
+ --noche-lampara:#f5c451; --noche-carta:#e2604f; --noche-verde:#4ec98a;
41
+ --noche-panel:#101720; --noche-burbuja:#171f2a; --noche-burbuja-mia:#27291f;
42
+ --noche-borde-acento:#65552d; --noche-sobre-lampara:#1a1408;
43
+ --noche-lampara-viva:#ffd469;
44
+ --noche-chip-luz:36% 30%; --noche-chip-tinta:#f3eee4;
45
+ --noche-tono-palabra:#aa93ff; --noche-tono-evidencia:#61ace9;
46
+ --noche-tono-verificacion:#50ccb1;
47
+ --noche-sombra:none;
44
48
  }
45
- /* One skin's worth of mapping, used by the explicit choice and by the system
46
- preference alike. An explicit choice always outranks the machine's. */
47
- :root[data-tema="claro"]{
48
- --suelo:var(--dia-suelo); --suelo2:var(--dia-suelo2); --suelo3:var(--dia-suelo3);
49
- --linea:var(--dia-linea); --linea2:var(--dia-linea2);
50
- --tinta:var(--dia-tinta); --tinta2:var(--dia-tinta2); --tinta3:var(--dia-tinta3);
51
- --lampara:var(--dia-lampara); --carta:var(--dia-carta); --verde:var(--dia-verde);
52
- --panel:var(--dia-panel); --burbuja:var(--dia-burbuja); --burbuja-mia:var(--dia-burbuja-mia);
53
- --borde-acento:var(--dia-borde-acento); --sobre-lampara:var(--dia-sobre-lampara);
54
- --lampara-viva:var(--dia-lampara-viva);
55
- --chip-luz:var(--dia-chip-luz); --chip-tinta:var(--dia-chip-tinta);
56
- --tono-palabra:var(--dia-tono-palabra); --tono-evidencia:var(--dia-tono-evidencia);
57
- --tono-verificacion:var(--dia-tono-verificacion);
58
- --sombra:var(--dia-sombra);
59
- }
60
- @media(prefers-color-scheme:light){
61
- :root:not([data-tema="oscuro"]){
62
- --suelo:var(--dia-suelo); --suelo2:var(--dia-suelo2); --suelo3:var(--dia-suelo3);
63
- --linea:var(--dia-linea); --linea2:var(--dia-linea2);
64
- --tinta:var(--dia-tinta); --tinta2:var(--dia-tinta2); --tinta3:var(--dia-tinta3);
65
- --lampara:var(--dia-lampara); --carta:var(--dia-carta); --verde:var(--dia-verde);
66
- --panel:var(--dia-panel); --burbuja:var(--dia-burbuja); --burbuja-mia:var(--dia-burbuja-mia);
67
- --borde-acento:var(--dia-borde-acento); --sobre-lampara:var(--dia-sobre-lampara);
68
- --lampara-viva:var(--dia-lampara-viva);
69
- --chip-luz:var(--dia-chip-luz); --chip-tinta:var(--dia-chip-tinta);
70
- --tono-palabra:var(--dia-tono-palabra); --tono-evidencia:var(--dia-tono-evidencia);
71
- --tono-verificacion:var(--dia-tono-verificacion);
72
- --tono-palabra:var(--dia-tono-palabra); --tono-evidencia:var(--dia-tono-evidencia);
73
- --tono-verificacion:var(--dia-tono-verificacion);
74
- --sombra:var(--dia-sombra);
75
- }
49
+ /* Night, mapped from the tokens declared once above. */
50
+ :root[data-tema="oscuro"]{
51
+ --suelo:var(--noche-suelo); --suelo2:var(--noche-suelo2); --suelo3:var(--noche-suelo3);
52
+ --linea:var(--noche-linea); --linea2:var(--noche-linea2);
53
+ --tinta:var(--noche-tinta); --tinta2:var(--noche-tinta2); --tinta3:var(--noche-tinta3);
54
+ --lampara:var(--noche-lampara); --carta:var(--noche-carta); --verde:var(--noche-verde);
55
+ --panel:var(--noche-panel); --burbuja:var(--noche-burbuja);
56
+ --burbuja-mia:var(--noche-burbuja-mia);
57
+ --borde-acento:var(--noche-borde-acento); --sobre-lampara:var(--noche-sobre-lampara);
58
+ --lampara-viva:var(--noche-lampara-viva);
59
+ --chip-luz:var(--noche-chip-luz); --chip-tinta:var(--noche-chip-tinta);
60
+ --tono-palabra:var(--noche-tono-palabra); --tono-evidencia:var(--noche-tono-evidencia);
61
+ --tono-verificacion:var(--noche-tono-verificacion);
62
+ --sombra:var(--noche-sombra);
76
63
  }
64
+ /* No `prefers-color-scheme` rule on purpose: daylight is THE default, not a
65
+ default that a machine set to dark quietly overrides. Night is a choice made
66
+ here, with the switch, and remembered — which is why an owner whose whole
67
+ desktop is dark still opens a paper-coloured hall. */
77
68
  *{box-sizing:border-box}
78
69
  html,body{height:100%}
79
70
  body{margin:0;background:var(--suelo);color:var(--tinta);font-family:var(--texto);
@@ -101,6 +92,13 @@ nav{background:var(--suelo2);border-right:1px solid var(--linea);padding:20px 0
101
92
  .badge::before{content:"";width:5px;height:5px;border-radius:50%;background:var(--lampara)}
102
93
  .marca h2{font-size:19px;margin:9px 0 0}
103
94
  .marca p{margin:4px 0 0;font-size:12px;color:var(--tinta3);line-height:1.45}
95
+ .railCiudad{margin:0 18px;padding:10px 12px;border-radius:7px;background:var(--suelo3);
96
+ border:1px solid var(--linea);display:flex;flex-direction:column;gap:2px}
97
+ .railCiudad .railEtiqueta{font:600 9px var(--hud);letter-spacing:.16em;text-transform:uppercase;
98
+ color:var(--tinta3)}
99
+ .railCiudad b{font:600 15px var(--hud);color:var(--tinta)}
100
+ .railCiudad .railDe{font:10.5px var(--mono);color:var(--tinta3)}
101
+ .railOtras{margin-top:6px;padding-top:6px;border-top:1px solid var(--linea)}
104
102
  nav ol{list-style:none;margin:0;padding:0 10px;display:flex;flex-direction:column;gap:2px}
105
103
  nav li{display:flex;gap:10px;align-items:baseline;padding:9px 10px;border-radius:3px;
106
104
  font-family:var(--hud);font-size:13.5px;color:var(--tinta2);cursor:pointer;white-space:nowrap}
@@ -110,12 +108,19 @@ nav li .n{font-family:var(--mono);font-size:10px;color:var(--tinta3);margin-left
110
108
  nav .pie{margin-top:auto;padding:14px 18px 0;border-top:1px solid var(--linea);
111
109
  display:flex;flex-direction:column;gap:8px}
112
110
  nav .pie a{font-family:var(--hud);font-size:12px;color:var(--tinta3);text-decoration:none}
111
+ .peligro{border:1px solid var(--carta);border-radius:8px;padding:16px 18px;
112
+ background:color-mix(in srgb,var(--carta) 7%,var(--suelo2));display:flex;
113
+ flex-direction:column;gap:10px;align-items:flex-start}
114
+ .peligro h3{font:600 13px var(--hud);color:var(--carta)}
115
+ .bt.malo{border-color:var(--carta);color:var(--carta)}
116
+ .bt.malo:hover{background:var(--carta);color:var(--suelo2)}
113
117
  .avisoVersion{margin:0;font:11px/1.45 var(--mono);color:var(--lampara);
114
118
  border:1px solid var(--borde-acento);background:var(--burbuja-mia);
115
119
  padding:7px 9px;border-radius:4px}
116
120
  .avisoVersion[hidden]{display:none}
117
- .temaBoton{align-self:flex-start;border:1px solid var(--linea2);background:var(--suelo3);
118
- color:var(--tinta2);font:11px var(--mono);padding:4px 9px;border-radius:4px;cursor:pointer}
121
+ .temaBoton{margin-top:12px;border:1px solid var(--linea2);background:var(--suelo3);
122
+ color:var(--tinta2);font:11px var(--mono);padding:5px 11px;border-radius:999px;
123
+ cursor:pointer;box-shadow:var(--sombra)}
119
124
  .temaBoton:hover{color:var(--lampara);border-color:var(--lampara)}
120
125
  nav .pie a:hover{color:var(--lampara)}
121
126
  #ciudades select{width:100%;background:var(--suelo);border:1px solid var(--linea2);
@@ -305,6 +310,51 @@ input[type=text]:focus,select:focus{outline:none;border-color:var(--lampara)}
305
310
  color:var(--lampara);font:12px var(--mono);padding:7px 14px;border-radius:5px;
306
311
  cursor:pointer}
307
312
  .rpgAlta:hover{background:var(--suelo);border-color:var(--lampara)}
313
+
314
+ /* ── the guided first run ────────────────────────────────────────────────── */
315
+ .bv{display:flex;flex-direction:column;gap:26px;max-width:min(760px,100%)}
316
+ .bv .prosa{max-width:100%}
317
+ .bv code.mono{word-break:break-all}
318
+ /* The live rail has nothing to show before a city exists, and it costs half
319
+ the screen exactly when the guide needs it. */
320
+ body.enGuia #livePanel,body.enGuia #liveResize{display:none}
321
+ body.enGuia #app{grid-template-columns:250px minmax(0,1fr)}
322
+ .bvPasos{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
323
+ .bvPunto{display:flex;align-items:center;gap:6px;font:10px var(--hud);letter-spacing:.1em;
324
+ text-transform:uppercase;color:var(--tinta3)}
325
+ .bvPunto i{width:8px;height:8px;border-radius:50%;background:var(--linea2);display:block}
326
+ .bvPunto+.bvPunto{margin-left:10px}
327
+ .bvPunto.aqui{color:var(--lampara)}.bvPunto.aqui i{background:var(--lampara)}
328
+ .bvPunto.hecho i{background:var(--verde)}
329
+ .bvHola{display:grid;grid-template-columns:auto 1fr;gap:26px;align-items:start}
330
+ @media(max-width:820px){.bvHola{grid-template-columns:1fr}}
331
+ .bvArte{display:flex;flex-direction:column;gap:2px;padding-top:6px}
332
+ .bvCasa{width:92px;height:83px;margin-bottom:-26px;filter:drop-shadow(var(--sombra))}
333
+ .bvPaso{display:flex;flex-direction:column;gap:14px}
334
+ .bvPaso h1{font-size:26px;line-height:1.15}
335
+ .bvEtiqueta{font:600 10px var(--hud);letter-spacing:.14em;text-transform:uppercase;
336
+ color:var(--tinta3)}
337
+ .bvRejilla{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px}
338
+ .bvRejilla.bvTres{grid-template-columns:repeat(auto-fit,minmax(210px,1fr))}
339
+ .bvOpcion{text-align:left;display:flex;flex-direction:column;gap:4px;padding:12px 13px;
340
+ border:1px solid var(--linea2);border-radius:7px;background:var(--suelo2);cursor:pointer}
341
+ .bvOpcion b{font-family:var(--hud);font-size:13px;color:var(--tinta)}
342
+ .bvOpcion span{font-size:12px;line-height:1.45;color:var(--tinta3)}
343
+ .bvOpcion:hover{border-color:var(--lampara)}
344
+ .bvOpcion.elegida{border-color:var(--lampara);background:var(--burbuja-mia);
345
+ box-shadow:inset 3px 0 0 var(--lampara)}
346
+ .bvChips{display:flex;flex-wrap:wrap;gap:6px}
347
+ .bvChip{border:1px solid var(--linea2);background:var(--suelo2);color:var(--tinta2);
348
+ font:11px var(--mono);padding:5px 10px;border-radius:999px;cursor:pointer}
349
+ .bvChip:hover{border-color:var(--lampara)}
350
+ .bvChip.elegida{border-color:var(--lampara);background:var(--burbuja-mia);color:var(--tinta)}
351
+ .bvFichas{display:flex;flex-direction:column;gap:6px}
352
+ .bvFicha{display:flex;gap:10px;align-items:baseline;padding:9px 12px;border-radius:6px;
353
+ border:1px solid var(--linea);background:var(--suelo2)}
354
+ .bvFicha b{font-family:var(--hud);font-size:13px}
355
+ .bvFicha span{font:11px var(--mono);color:var(--tinta3)}
356
+ .bvBotones{display:flex;gap:9px;flex-wrap:wrap;margin-top:4px}
357
+ .bvSiguientes{display:flex;gap:9px;flex-wrap:wrap}
308
358
  /* The instruction editor: one file, one reader, saved where the agent lives. */
309
359
  #editorIns{position:fixed;inset:0;background:rgba(6,10,15,.72);z-index:60;
310
360
  display:flex;align-items:center;justify-content:center}
@@ -442,15 +492,16 @@ input[type=text]:focus,select:focus{outline:none;border-color:var(--lampara)}
442
492
  <span class="badge">town hall</span>
443
493
  <h2>Agents City</h2>
444
494
  <p>Manage one autonomous city: its seat, repo agents, roads and map. Everything here is a plain file you can also inspect by hand.</p>
495
+ <button type="button" id="temaBoton" class="temaBoton"
496
+ title="Daylight is the default; night is the map's own light">☾ Night</button>
445
497
  </div>
498
+ <div id="railCiudad" class="railCiudad"></div>
446
499
  <ol id="rail"></ol>
500
+ <ol id="railOtras" class="railOtras"></ol>
447
501
  <div id="ciudades" style="padding:0 18px"></div>
448
502
  <div class="pie">
449
503
  <p id="avisoVersion" class="avisoVersion" hidden></p>
450
504
  <a href="#" id="alWizard">Create another city →</a>
451
- <button type="button" id="temaBoton" class="temaBoton"
452
- title="Switch between the night city and the daylight palette of agentscity.net">
453
- ☾ Night</button>
454
505
  <span class="dato" id="dondeDatos"></span>
455
506
  </div>
456
507
  </nav>
package/bin/navegador.mjs CHANGED
@@ -108,6 +108,7 @@ function comprueba(texto, bien, detalle = '') {
108
108
 
109
109
  async function main() {
110
110
  const url = process.argv[2];
111
+ const ciudadVacia = process.argv[3] || '';
111
112
  if (!url) {
112
113
  console.error('usage: navegador.mjs <hall url>');
113
114
  process.exit(2);
@@ -176,8 +177,8 @@ async function main() {
176
177
 
177
178
  // The agents view: this is where dead controls shipped once.
178
179
  const abierto = await cdp.evalua(`(async () => {
179
- const ir = [...document.querySelectorAll('nav li')].find(l => /agents/i.test(l.textContent));
180
- if (!ir) return { falta: 'no agents entry' };
180
+ const ir = [...document.querySelectorAll('nav li')].find(l => /houses/i.test(l.textContent));
181
+ if (!ir) return { falta: 'no houses entry' };
181
182
  ir.click();
182
183
  for (let i = 0; i < 40; i++) {
183
184
  if (document.querySelector('.fichaRPG')) break;
@@ -194,9 +195,9 @@ async function main() {
194
195
  test: boton('.rpgTest'),
195
196
  };
196
197
  })()`);
197
- comprueba('the agents view renders its sheets', (abierto?.fichas ?? 0) > 0, JSON.stringify(abierto));
198
+ comprueba('the houses view renders its sheets', (abierto?.fichas ?? 0) > 0, JSON.stringify(abierto));
198
199
  for (const [control, etiqueta] of [
199
- ['alta', '+ Add an agent'],
200
+ ['alta', 'the build-a-house button'],
200
201
  ['instrucciones', 'the CLAUDE.md / AGENTS.md editors'],
201
202
  ['subir', 'the skill zip upload'],
202
203
  ['monta', 'the + folder mount button'],
@@ -228,12 +229,97 @@ async function main() {
228
229
  JSON.stringify(modal),
229
230
  );
230
231
 
232
+ // The guided first run, checked against a city with nobody in it: that is
233
+ // the only state where it appears, and it is the screen that used to say
234
+ // "Not drawn yet" to somebody arriving for the first time.
235
+ if (ciudadVacia) {
236
+ await cdp.manda('Page.navigate', {
237
+ url: url + '&city=' + encodeURIComponent(ciudadVacia),
238
+ });
239
+ for (let i = 0; i < 60; i += 1) {
240
+ const listo = await cdp.evalua('!!document.getElementById("rail") && !!window.PASE');
241
+ if (listo) break;
242
+ await dormir(250);
243
+ }
244
+ }
245
+ const guia = await cdp.evalua(`(async () => {
246
+ for (let i = 0; i < 40; i++) {
247
+ if (document.querySelector('.bvPasos')) break;
248
+ await new Promise(r => setTimeout(r, 250));
249
+ }
250
+ const pasos = [...document.querySelectorAll('.bvPunto')].map(p => p.textContent.trim());
251
+ const b = document.querySelector('[data-bv="siguiente"]');
252
+ if (b) b.click();
253
+ await new Promise(r => setTimeout(r, 900));
254
+ return {
255
+ pasos,
256
+ titulo: document.querySelector('.bvPaso h1')?.textContent?.trim() ?? '',
257
+ opciones: document.querySelectorAll('.bvOpcion').length,
258
+ };
259
+ })()`);
260
+ comprueba(
261
+ 'a city with no agents opens the guide, not an empty map',
262
+ (guia?.pasos?.length ?? 0) >= 4,
263
+ JSON.stringify(guia),
264
+ );
265
+ comprueba(
266
+ 'and its first question offers the work domains to choose from',
267
+ /work happens here/i.test(guia?.titulo ?? '') && (guia?.opciones ?? 0) > 3,
268
+ JSON.stringify(guia),
269
+ );
270
+
271
+ // Cities: a person must be able to start and retire one without a terminal.
272
+ const ciudades = await cdp.evalua(`(async () => {
273
+ const ir = [...document.querySelectorAll('nav li')].find(l => /cities/i.test(l.textContent));
274
+ if (!ir) return { falta: true };
275
+ ir.click();
276
+ await new Promise(r => setTimeout(r, 700));
277
+ return {
278
+ crear: !!document.getElementById('creaCiudad'),
279
+ campo: !!document.getElementById('nuevaCiudad'),
280
+ filas: document.querySelectorAll('.lista .fila').length,
281
+ };
282
+ })()`);
283
+ const reinicio = await cdp.evalua(`(async () => {
284
+ const b = document.getElementById('reiniciaCiudad');
285
+ if (!b) return { falta: true };
286
+ // Nothing is clicked here: this asks the server what a reset WOULD do,
287
+ // which is the same call the button makes before it dares ask anything.
288
+ const r = await fetch('/api/ciudad-reinicia?PASE=' + window.PASE, {
289
+ method: 'POST',
290
+ headers: { 'X-City-Pase': window.PASE, 'Content-Type': 'application/json' },
291
+ body: '{}',
292
+ }).then((x) => x.json());
293
+ return { cableado: !!b.onclick, ok: r.ok, previa: r.preview, error: r.error };
294
+ })()`);
295
+ comprueba(
296
+ 'the danger button exists and is wired',
297
+ reinicio?.cableado === true,
298
+ JSON.stringify(reinicio),
299
+ );
300
+ comprueba(
301
+ // Two acceptable answers, one forbidden: a preview of what would happen,
302
+ // or a refusal (this fixture's city lives outside the owner's folder and
303
+ // reset declines to touch such a thing). What must NEVER come back from a
304
+ // call with no typed name is ok:true — that would be a reset on one click.
305
+ 'without the typed name it previews or refuses, but never resets',
306
+ reinicio?.ok !== true &&
307
+ (Array.isArray(reinicio?.previa?.loses) || typeof reinicio?.error === 'string'),
308
+ JSON.stringify(reinicio),
309
+ );
310
+
311
+ comprueba(
312
+ 'the Cities section can start a new city',
313
+ ciudades?.crear === true && ciudades?.campo === true,
314
+ JSON.stringify(ciudades),
315
+ );
316
+
231
317
  const errores = await cdp.evalua('window.__erroresDePagina ? window.__erroresDePagina.length : 0');
232
318
  comprueba('the page raised no uncaught errors while we drove it', errores === 0, String(errores));
233
319
  } finally {
234
320
  proceso.kill('SIGKILL');
235
321
  }
236
- console.log(`\n ${fallos ? `${fallos} failed` : 'browser ok'} — 11 checks\n`);
322
+ console.log(`\n ${fallos ? `${fallos} failed` : 'browser ok'} — 16 checks\n`);
237
323
  process.exit(fallos ? 1 : 0);
238
324
  }
239
325
 
package/bin/serve.py CHANGED
@@ -48,6 +48,7 @@ import avatar # noqa: E402
48
48
  import workspace # noqa: E402
49
49
  import crecimiento # noqa: E402
50
50
  import runtime_processes # noqa: E402
51
+ import reset as reinicio # noqa: E402
51
52
  import actualiza # noqa: E402
52
53
  import importlib.machinery as _mach
53
54
  import importlib.util as _iu # noqa: E402
@@ -175,6 +176,16 @@ def skills_de_ciudad(datos):
175
176
  return valor
176
177
 
177
178
 
179
+ def estado_seguro_agentes(datos):
180
+ """How many agents this city has, or none when the card cannot be read."""
181
+ owner = cities.lee_clave(datos, "owner") or seat.quien_soy()
182
+ try:
183
+ texto = card.lee(os.path.join(datos, f"{owner}.md")).get("texto") or ""
184
+ return workspace.agentes(texto, datos)
185
+ except (OSError, ValueError):
186
+ return []
187
+
188
+
178
189
  def olvida_skills():
179
190
  """Forget the discovery memo: a skill was installed or removed, and that
180
191
  changes the answer without touching the card."""
@@ -426,10 +437,20 @@ def lee_clave(fichero, clave, defecto=""):
426
437
 
427
438
 
428
439
  def lista_con(datos):
440
+ """Every city this owner has, with the one being looked at marked — and how
441
+ many agents live in each.
442
+
443
+ The count is not decoration: an agent belongs to ONE city because its
444
+ workspace and its mounts live inside that city's folder, and a menu that
445
+ lists "Cities" and "Agents" as two unrelated things hides exactly that. One
446
+ card read per city, which is cheaper than the page that draws it.
447
+ """
429
448
  real = os.path.realpath(datos)
430
449
  fuera = [dict(c, actual=(c["ruta"] == real)) for c in cities.lista(seat.quien_soy())]
431
450
  if not any(c["actual"] for c in fuera):
432
451
  fuera.insert(0, {"ruta": real, "nombre": cities.nombre(real), "actual": True})
452
+ for c in fuera:
453
+ c["agentes"] = len(estado_seguro_agentes(c["ruta"]))
433
454
  return fuera
434
455
 
435
456
 
@@ -679,6 +700,8 @@ class Manejador(http.server.BaseHTTPRequestHandler):
679
700
  "/api/mapa": "p_mapa",
680
701
  "/api/sesion": "p_sesion",
681
702
  "/api/ciudades": "p_ciudades",
703
+ "/api/ciudad-archiva": "p_archiva_ciudad",
704
+ "/api/ciudad-reinicia": "p_reinicia_ciudad",
682
705
  "/api/roads": "p_roads",
683
706
  "/api/agente": "p_agente",
684
707
  "/api/agentes": "p_agentes",
@@ -1049,6 +1072,70 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1049
1072
  {"ok": True, "city": datos, "address": cities.direccion(usuario, datos)}
1050
1073
  )
1051
1074
 
1075
+ def p_archiva_ciudad(self, q, cuerpo):
1076
+ """Take one city out of use — recoverably, and never the last one.
1077
+
1078
+ Not a delete: `cities.archiva` MOVES the folder into the owner's
1079
+ backups. A city is somebody's cards, deliberations and map, and a
1080
+ product that erases that on one click will erase the wrong one.
1081
+ """
1082
+ pedida = str(cuerpo.get("city") or "")
1083
+ usuario = seat.quien_soy()
1084
+ datos = cities.resuelve(pedida, usuario) if pedida else ""
1085
+ if not datos:
1086
+ return self.responde({"error": f"no city called {pedida!r}"}, codigo=404)
1087
+ try:
1088
+ copia = cities.archiva(datos, usuario)
1089
+ except (OSError, ValueError) as e:
1090
+ return self.responde({"error": str(e)}, codigo=409)
1091
+ return self.responde(
1092
+ {"ok": True, "backup": copia, "ciudades": lista_con(cities.actual(usuario))}
1093
+ )
1094
+
1095
+ def p_reinicia_ciudad(self, q, cuerpo):
1096
+ """Take a city back to its first day — after showing exactly what that
1097
+ means, and only when the person types its name.
1098
+
1099
+ Two calls, on purpose. Without `confirm` it answers with the effects and
1100
+ changes nothing; with `confirm` matching the city's own name it does the
1101
+ work. A dangerous button that fires on one click will eventually be
1102
+ clicked by an elbow, and the answer "are you sure?" is not information —
1103
+ the list below is.
1104
+ """
1105
+ datos = self.ciudad(q)
1106
+ usuario = cities.lee_clave(datos, "owner") or seat.quien_soy()
1107
+ nombre = cities.nombre(datos)
1108
+ try:
1109
+ copia = reinicio.reinicia(datos, usuario, dry_run=True)
1110
+ except (OSError, ValueError) as e:
1111
+ return self.responde({"error": str(e)}, codigo=409)
1112
+ agentes = len(estado_seguro_agentes(datos))
1113
+ efectos = {
1114
+ "city": nombre,
1115
+ "backup": copia,
1116
+ "agents": agentes,
1117
+ "roads": len(roads.lee(datos)),
1118
+ "deliberations": len(deliberations.lista(datos)),
1119
+ "keeps": [
1120
+ "every repository and folder your agents mount — untouched",
1121
+ f"a full copy of this city at {copia}",
1122
+ ],
1123
+ "loses": [
1124
+ "your seat card: role, goal and the whole roster",
1125
+ "each agent's workspace, its mounts and the skills installed in it",
1126
+ "the committee history and every recorded decision",
1127
+ "the map's districts and houses",
1128
+ ],
1129
+ }
1130
+ if str(cuerpo.get("confirm") or "") != nombre:
1131
+ return self.responde({"ok": False, "preview": efectos})
1132
+ try:
1133
+ copia = reinicio.reinicia(datos, usuario)
1134
+ except (OSError, ValueError) as e:
1135
+ return self.responde({"error": str(e)}, codigo=409)
1136
+ olvida_skills()
1137
+ return self.responde({"ok": True, "backup": copia, "preview": efectos})
1138
+
1052
1139
  def p_roads(self, q, cuerpo):
1053
1140
  origen = self.ciudad(q)
1054
1141
  accion = str(cuerpo.get("action") or "")
@@ -438,6 +438,50 @@ def skills_transparentes():
438
438
  comprueba("· there is no stale capability cache", nombres, ["safe-deploy", "audit"])
439
439
 
440
440
 
441
+ def reset_de_varias():
442
+ """`reset` takes several cities, or `all` — and stops before touching any
443
+ of them when one name is wrong.
444
+
445
+ Resetting three of four cities and then failing on a typo is the worst
446
+ possible outcome of a destructive command, so resolution happens first and
447
+ completely.
448
+ """
449
+ print(" resetting more than one city")
450
+ base = tempfile.mkdtemp()
451
+ previo = {k: os.environ.get(k) for k in ("AGENTS_CITY_HOME", "AGENTS_CITY_USER")}
452
+ os.environ.update(AGENTS_CITY_HOME=base, AGENTS_CITY_USER="ana")
453
+ registro = cities.REGISTRO
454
+ cities.REGISTRO = os.path.join(base, "reg")
455
+ try:
456
+ for n in ("home", "producto", "cliente"):
457
+ cities.crea("ana", n)
458
+ entorno = dict(os.environ, AGENTS_CITY_HOME=base, AGENTS_CITY_USER="ana")
459
+ guion = os.path.join(RAIZ, "plugin", "scripts", "reset.py")
460
+
461
+ r = subprocess.run(["python3", guion, "all", "--dry-run"],
462
+ capture_output=True, text=True, env=entorno)
463
+ comprueba("· `all` covers every city this owner has",
464
+ sum(1 for ln in r.stdout.splitlines() if "Would reset" in ln), 3)
465
+ r = subprocess.run(["python3", guion, "home", "cliente", "--dry-run"],
466
+ capture_output=True, text=True, env=entorno)
467
+ afirma("· several names, space separated, cover exactly those",
468
+ "`home`" in r.stdout and "`cliente`" in r.stdout and "`producto`" not in r.stdout,
469
+ r.stdout)
470
+ r = subprocess.run(["python3", guion, "home", "inventada", "--dry-run"],
471
+ capture_output=True, text=True, env=entorno)
472
+ afirma("· one unknown name aborts the whole run, before any city is touched",
473
+ r.returncode == 1 and "Nothing was reset" in r.stderr, r.stderr)
474
+ comprueba("· so nothing was reset", len(cities.lista("ana")), 3)
475
+ finally:
476
+ cities.REGISTRO = registro
477
+ for k, v in previo.items():
478
+ if v is None:
479
+ os.environ.pop(k, None)
480
+ else:
481
+ os.environ[k] = v
482
+ shutil.rmtree(base, ignore_errors=True)
483
+
484
+
441
485
  def main():
442
486
  print()
443
487
  identidad_y_catalogo()
@@ -446,6 +490,7 @@ def main():
446
490
  carreteras()
447
491
  reinicio_aislado()
448
492
  skills_transparentes()
493
+ reset_de_varias()
449
494
  return resumen("cities")
450
495
 
451
496
 
@@ -95,13 +95,20 @@ def main():
95
95
  )
96
96
  ciudad_de_prueba(datos)
97
97
 
98
+ # A SECOND city, deliberately empty: the guide only appears where there is
99
+ # nothing yet, and the first fixture has an agent so the sheets can render.
100
+ try:
101
+ vacia = serve.cities.crea("navtest", "sin-nadie", usar=False)
102
+ except (OSError, ValueError):
103
+ vacia = ""
104
+
98
105
  servidor = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
99
106
  hilo = threading.Thread(target=servidor.serve_forever, daemon=True)
100
107
  hilo.start()
101
108
  url = f"http://127.0.0.1:{servidor.server_port}/?PASE={serve.PASE}"
102
109
  try:
103
110
  salida = subprocess.run(
104
- ["node", os.path.join(AQUI, "navegador.mjs"), url],
111
+ ["node", os.path.join(AQUI, "navegador.mjs"), url, vacia],
105
112
  capture_output=True,
106
113
  text=True,
107
114
  timeout=300,
package/bin/test-seat.py CHANGED
@@ -518,7 +518,7 @@ def maquinas_hostiles():
518
518
  "· an old cached Claude plugin is updated through Claude's own CLI",
519
519
  llamadas
520
520
  and llamadas[0][:4] == ["claude", "plugin", "update", "city@agents-city"]
521
- and "0.1.0" in salida.getvalue(),
521
+ and "0.2.1" in salida.getvalue(),
522
522
  f"calls={llamadas!r} output={salida.getvalue()!r}",
523
523
  )
524
524