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
package/bin/test-serve.py CHANGED
@@ -169,6 +169,53 @@ def main():
169
169
  and b".liveAvatar" in cuerpo,
170
170
  cuerpo[:500].decode(errors="replace"),
171
171
  )
172
+ # Two skins, one set of names. A colour written straight into a rule is
173
+ # a colour that exists in one theme only — the classic unreadable-page
174
+ # bug — so the palette is asserted to be the only place naming one.
175
+ pagina = cuerpo.decode(errors="replace")
176
+ cabecera = pagina.split("*{box-sizing", 1)[0]
177
+ cuerpo_css = pagina.split("*{box-sizing", 1)[1].split("</style>", 1)[0]
178
+ afirma(
179
+ "· daylight is the default skin, in the tokens of agentscity.net",
180
+ "--suelo:#f3f0e8" in cabecera
181
+ and "--tinta:#1d2229" in cabecera
182
+ and "--lampara:#7048e8" in cabecera,
183
+ cabecera[-500:],
184
+ )
185
+ afirma(
186
+ "· night is declared once and only mapped, never written twice",
187
+ "--noche-suelo:#131a24" in cabecera
188
+ and cabecera.count("--noche-suelo:#131a24") == 1,
189
+ cabecera[-500:],
190
+ )
191
+ afirma(
192
+ # Daylight is THE default, not a default a dark desktop overrides:
193
+ # the owner asked for light and a machine setting does not outvote
194
+ # them. Night is reachable only through the switch, and remembered.
195
+ "· night is a choice made here, never one the machine makes",
196
+ '[data-tema="oscuro"]' in cabecera
197
+ # The RULE, not the word: the palette block explains in prose
198
+ # that there deliberately is no media query, and a test that
199
+ # cannot tell a comment from a rule fails on its own docs.
200
+ and "@media(prefers-color-scheme" not in cabecera,
201
+ cabecera[-700:],
202
+ )
203
+ import re as _re
204
+
205
+ sueltos = [
206
+ c for c in _re.findall(r"#[0-9a-fA-F]{6}\b", cuerpo_css)
207
+ # The kind chips are brand identity, the same hue in both skins.
208
+ if c.lower() not in ("#3b82f6", "#8b5cf6", "#f59e0b", "#0f172a", "#f4efe5")
209
+ ]
210
+ afirma(
211
+ "· and no rule outside the palette hardcodes a colour of its own",
212
+ not sueltos, ", ".join(sorted(set(sueltos))),
213
+ )
214
+ afirma(
215
+ "· the switch is in the page, and says which skin is showing",
216
+ 'id="temaBoton"' in pagina, "",
217
+ )
218
+
172
219
  map_html = open(os.path.join(RAIZ, "city", "web", "index.html")).read()
173
220
  hall_source = open(os.path.join(RAIZ, "city", "web", "src", "hall.ts")).read()
174
221
  speech_source = open(os.path.join(RAIZ, "city", "web", "src", "game-speech.ts")).read()
@@ -283,6 +330,10 @@ def main():
283
330
  )
284
331
 
285
332
  # A seat written through the hall is the same card as everywhere else.
333
+ # The roster is NOT part of it: agents are added through /api/agentes,
334
+ # and saving the seat must carry them over untouched — the whole point
335
+ # of having one writer per fact. The card comes first: an agent lives on
336
+ # its owner's card, so there is nothing to join until that exists.
286
337
  st, cuerpo = pide(
287
338
  puerto,
288
339
  "/api/ficha",
@@ -291,8 +342,6 @@ def main():
291
342
  "user": "Ana.López@x.com",
292
343
  "domain": "healthcare",
293
344
  "role": "clinical-director",
294
- "repos": ["api"],
295
- "repo_roles": {"api": "seo"},
296
345
  "objetivo": {
297
346
  "title": "One goal",
298
347
  "signal": "s",
@@ -306,11 +355,33 @@ def main():
306
355
  r = json.loads(cuerpo)
307
356
  comprueba("· a card is written through the hall", st, 200)
308
357
  comprueba("· body.user cannot create a second person in the city", r["user"], owner)
358
+ st_alta, cuerpo_alta = pide(
359
+ puerto, "/api/agentes", metodo="POST",
360
+ cuerpo={"name": "api", "kind": "code", "role": "seo"},
361
+ )
362
+ comprueba("· an agent joins the city through its own endpoint", st_alta, 200)
363
+ # Saving the seat a second time is where the old shape would have
364
+ # silently replaced the roster with whatever the page happened to send.
365
+ pide(
366
+ puerto, "/api/ficha", metodo="POST",
367
+ cuerpo={"domain": "healthcare", "role": "clinical-director"},
368
+ )
309
369
  c = card.lee(os.path.join(hallDatos, f"{owner}.md"))
370
+ texto_ficha = c.get("texto") or ""
310
371
  comprueba(
311
372
  "· and reads back through the shared reader",
312
- (c["agent"], c["role"], c["repos"], c["repo_roles"]),
313
- (address, "clinical-director", ["api"], {"api": "seo"}),
373
+ (c["agent"], c["role"]),
374
+ (address, "clinical-director"),
375
+ )
376
+ afirma(
377
+ "· saving the seat carries the roster over instead of emptying the city",
378
+ "agents: [api]" in texto_ficha and "role.api: seo" in texto_ficha,
379
+ texto_ficha.split("---")[1],
380
+ )
381
+ afirma(
382
+ "· and nothing writes the legacy repos: shape any more",
383
+ "repos:" not in texto_ficha,
384
+ texto_ficha.split("---")[1],
314
385
  )
315
386
  estado_salud = json.loads(pide(puerto, "/api/estado")[1])
316
387
  afirma(
@@ -334,6 +405,9 @@ def main():
334
405
  "· an empty command is manual, not an empty string to run", c["objetivo"]["command"], ""
335
406
  )
336
407
 
408
+ # The seat endpoint no longer takes a roster in any shape: sending one
409
+ # is ignored rather than obeyed, because agents have their own door and
410
+ # a second way in is how the two doors drifted apart in the first place.
337
411
  st, cuerpo = pide(
338
412
  puerto,
339
413
  "/api/ficha",
@@ -341,14 +415,15 @@ def main():
341
415
  cuerpo={
342
416
  "domain": "healthcare",
343
417
  "role": "clinical-director",
344
- "repos": ["api"],
345
- "repo_roles": [],
418
+ "repos": ["inventado"],
419
+ "repo_roles": {"inventado": "seo"},
346
420
  },
347
421
  )
422
+ texto_tras = card.lee(os.path.join(hallDatos, f"{owner}.md")).get("texto") or ""
348
423
  afirma(
349
- malformed repo-role mappings are a visible 400",
350
- st == 400 and "repo_roles must be an object" in json.loads(cuerpo)["error"],
351
- cuerpo.decode(),
424
+ a roster sent to the seat endpoint is ignored, not written",
425
+ st == 200 and "inventado" not in texto_tras and "agents: [api]" in texto_tras,
426
+ texto_tras.split("---")[1],
352
427
  )
353
428
 
354
429
  # A goal with no title is no goal, not a goal called nothing.
@@ -664,6 +739,92 @@ def main():
664
739
  )
665
740
  pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": ""})
666
741
 
742
+ # ── the roster, from the web ─────────────────────────────────────────
743
+ print(" building the roster from the Hall")
744
+ st, cuerpo = pide(
745
+ puerto,
746
+ "/api/agentes",
747
+ metodo="POST",
748
+ cuerpo={"name": "urgencias web", "kind": "knowledge", "role": "triage"},
749
+ )
750
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
751
+ afirma(
752
+ "· happy: an agent can be added without ever opening a terminal",
753
+ st == 200
754
+ and "urgencias web" in carta
755
+ and "kind.urgencias-web: knowledge" in carta
756
+ and "role.urgencias-web: triage" in carta,
757
+ carta,
758
+ )
759
+ st, _ = pide(
760
+ puerto, "/api/agentes", metodo="POST",
761
+ cuerpo={"name": "urgencias web", "kind": "code", "role": "dev"},
762
+ )
763
+ comprueba("· non-happy: two agents cannot share one identity", st, 409)
764
+ st, _ = pide(
765
+ puerto, "/api/agentes", metodo="POST",
766
+ cuerpo={"name": "nova", "kind": "wizard", "role": "dev"},
767
+ )
768
+ comprueba("· non-happy: a kind is code, knowledge or coordinator", st, 400)
769
+ st, _ = pide(
770
+ puerto, "/api/agentes", metodo="POST",
771
+ cuerpo={"name": "nova", "kind": "code", "role": "dev; rm -rf /"},
772
+ )
773
+ comprueba("· non-happy: a role is an id, never a shell fragment", st, 400)
774
+ st, _ = pide(puerto, "/api/agentes", metodo="POST", cuerpo={"name": " "})
775
+ comprueba("· non-happy: an agent needs a name", st, 400)
776
+
777
+ # Mounts: what an agent works on, from the same page.
778
+ carpeta = tempfile.mkdtemp()
779
+ st, cuerpo = pide(
780
+ puerto, "/api/montaje", metodo="POST",
781
+ cuerpo={"agent": "urgencias-web", "add": carpeta},
782
+ )
783
+ montado = json.loads(cuerpo)
784
+ # The label is slugged: it is a card key and a filename, not free text.
785
+ etiqueta = card.ventana(os.path.basename(carpeta))
786
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
787
+ afirma(
788
+ "· happy: a document folder is mounted, on disk and on the card together",
789
+ st == 200
790
+ and any(m["label"] == etiqueta for m in montado.get("mounts", []))
791
+ and f"mounts.urgencias-web: [{os.path.realpath(carpeta)}]" in carta,
792
+ f"{cuerpo!r} {carta}",
793
+ )
794
+ st, cuerpo = pide(puerto, "/api/estado")
795
+ hoja_web = next(
796
+ x for x in json.loads(cuerpo)["agents"] if x["slug"] == "urgencias-web"
797
+ )
798
+ afirma(
799
+ "· and the sheet says what it works on, not merely how many",
800
+ [m["label"] for m in hoja_web["mounts"]] == [etiqueta],
801
+ str(hoja_web["mounts"]),
802
+ )
803
+ st, _ = pide(
804
+ puerto, "/api/montaje", metodo="POST",
805
+ cuerpo={"agent": "urgencias-web", "add": "/nope/not/here"},
806
+ )
807
+ comprueba("· non-happy: a folder that is not there is refused", st, 400)
808
+ st, cuerpo = pide(
809
+ puerto, "/api/montaje", metodo="POST",
810
+ cuerpo={"agent": "urgencias-web", "remove": etiqueta},
811
+ )
812
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
813
+ afirma(
814
+ "· happy: unmounting clears the link and the card key, and keeps the folder",
815
+ st == 200
816
+ and not json.loads(cuerpo)["mounts"]
817
+ and "mounts.urgencias-web" not in carta
818
+ and os.path.isdir(carpeta),
819
+ carta,
820
+ )
821
+ st, _ = pide(
822
+ puerto, "/api/montaje", metodo="POST",
823
+ cuerpo={"agent": "urgencias-web", "remove": "fantasma"},
824
+ )
825
+ comprueba("· non-happy: unmounting what is not mounted is a 404", st, 404)
826
+ shutil.rmtree(carpeta, ignore_errors=True)
827
+
667
828
  # ── instructions and skills, into the agent's own home ──────────────
668
829
  print(" instructions and skills, written only into the agent's own home")
669
830
  st, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
@@ -1006,6 +1167,50 @@ def main():
1006
1167
  if otro.get("s"):
1007
1168
  otro["s"].server_close()
1008
1169
 
1170
+ # Two memos serve /api/estado, and a memo without an invalidation is a
1171
+ # lie waiting to be told: mounting a folder changes exactly what growth
1172
+ # counts, and installing a skill changes discovery without touching the
1173
+ # card that keys it. A healthy card first — the checks above deliberately
1174
+ # left a broken one behind.
1175
+ with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
1176
+ f.write(
1177
+ "---\nuser: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
1178
+ "agents: [notas]\nkind.notas: knowledge\nrole.notas: apuntes\n"
1179
+ "goals_defined: true\n---\n"
1180
+ )
1181
+ serve._CRECIDO[(os.path.realpath(hallDatos), "notas")] = (
1182
+ __import__("time").monotonic(),
1183
+ {"floors": 999, "bricks": 0, "activity30": 0, "signal": "stale"},
1184
+ )
1185
+ pide(
1186
+ puerto, "/api/montaje", metodo="POST",
1187
+ cuerpo={"agent": "notas", "add": tempfile.mkdtemp()},
1188
+ )
1189
+ afirma(
1190
+ "· mounting a folder forgets that agent's remembered growth",
1191
+ (os.path.realpath(hallDatos), "notas") not in serve._CRECIDO,
1192
+ str(sorted(serve._CRECIDO)),
1193
+ )
1194
+ st, cuerpo = pide(puerto, "/api/estado")
1195
+ primera = json.loads(cuerpo)["skills"]
1196
+ sello_antes = serve._SKILLS.get("sello")
1197
+ pide(puerto, "/api/estado")
1198
+ afirma(
1199
+ "· discovery is remembered while the card has not moved",
1200
+ sello_antes is not None and serve._SKILLS.get("sello") == sello_antes,
1201
+ str(sello_antes),
1202
+ )
1203
+ pide(
1204
+ puerto, "/api/agentes", metodo="POST",
1205
+ cuerpo={"name": "efimero", "kind": "code", "role": "dev"},
1206
+ )
1207
+ st, cuerpo = pide(puerto, "/api/estado")
1208
+ afirma(
1209
+ "· and a card that moves invalidates it at once, not on a timer",
1210
+ "efimero" in json.loads(cuerpo)["skills"] and "efimero" not in primera,
1211
+ str(sorted(json.loads(cuerpo)["skills"])),
1212
+ )
1213
+
1009
1214
  # A code agent's growth reads real local git history — merge commits
1010
1215
  # as floors — instead of answering zero for lack of a counter.
1011
1216
  import subprocess as _sp
@@ -179,8 +179,71 @@ def _rechaza(fn):
179
179
  return True
180
180
 
181
181
 
182
+ def skills_instaladas():
183
+ """The one deliberate write: a skill the owner hands over, into one home.
184
+
185
+ Both doors (the wizard's question, the Hall's upload) come through here, so
186
+ the containment rules are tested once — where they live.
187
+ """
188
+ print(" installing a skill into one agent's own home")
189
+ base = tempfile.mkdtemp(prefix="agents-city-skill-")
190
+ try:
191
+ origen = os.path.join(base, "triage-skill")
192
+ os.makedirs(origen)
193
+ with open(os.path.join(origen, "SKILL.md"), "w") as f:
194
+ f.write("---\nname: triage\n---\n")
195
+ nombre, mal = ws.instala_skill(base, "urgencias", origen)
196
+ destino = os.path.join(ws.workspace_de(base, "urgencias"), ".claude", "skills",
197
+ "triage-skill", "SKILL.md")
198
+ afirma("· a folder with a SKILL.md installs into the agent's home",
199
+ nombre == "triage-skill" and not mal and os.path.isfile(destino), f"{nombre} {mal}")
200
+ _, otra_vez = ws.instala_skill(base, "urgencias", origen)
201
+ afirma("· installing the same skill twice is refused, never overwritten",
202
+ "already exists" in otra_vez, otra_vez)
203
+ sin = os.path.join(base, "not-a-skill")
204
+ os.makedirs(sin)
205
+ _, mal = ws.instala_skill(base, "urgencias", sin)
206
+ afirma("· a folder with no SKILL.md is not a skill", "SKILL.md" in mal, mal)
207
+ _, mal = ws.instala_skill(base, "urgencias", os.path.join(base, "ghost"))
208
+ afirma("· a source that is not there is an answer, not a traceback",
209
+ "nothing at" in mal, mal)
210
+
211
+ # A committed `.claude/skills` symlink would carry the install straight
212
+ # out of the agent's home — the global skills folder included.
213
+ fuera = os.path.join(base, "elsewhere")
214
+ os.makedirs(fuera)
215
+ hogar = ws.crea_workspace(base, "fugado")
216
+ os.makedirs(os.path.join(hogar, ".claude"), exist_ok=True)
217
+ os.symlink(fuera, os.path.join(hogar, ".claude", "skills"))
218
+ _, mal = ws.instala_skill(base, "fugado", origen)
219
+ afirma("· a symlinked skills root refuses the install and writes nothing",
220
+ "link out of its home" in mal and not os.listdir(fuera),
221
+ f"{mal} {os.listdir(fuera)}")
222
+
223
+ # A zip is the same skill by another door, through the same guards.
224
+ import io
225
+ import zipfile
226
+ crudo = os.path.join(base, "bomba.zip")
227
+ with zipfile.ZipFile(crudo, "w", zipfile.ZIP_DEFLATED) as z:
228
+ z.writestr("boom/SKILL.md", "---\nname: boom\n---\n")
229
+ z.writestr("boom/ceros.bin", b"\0" * (80 * 1024 * 1024))
230
+ _, mal = ws.instala_skill(base, "urgencias", crudo)
231
+ afirma("· a zip bomb is refused by what it inflates to, not what it weighs",
232
+ "inflates too large" in mal, mal)
233
+ bueno = os.path.join(base, "buena.zip")
234
+ with zipfile.ZipFile(bueno, "w") as z:
235
+ z.writestr("deploy/SKILL.md", "---\nname: deploy\n---\n")
236
+ nombre, mal = ws.instala_skill(base, "urgencias", bueno)
237
+ afirma("· a well-formed zip installs under its own folder name",
238
+ nombre == "deploy" and not mal, f"{nombre} {mal}")
239
+ del io
240
+ finally:
241
+ shutil.rmtree(base, ignore_errors=True)
242
+
243
+
182
244
  legacy_subsume()
183
245
  nuevo_modelo()
246
+ skills_instaladas()
184
247
  montajes()
185
248
  colisiones_de_basename()
186
249
  slugs_colisionados_rechazados()
package/bin/testlib.py CHANGED
@@ -93,3 +93,26 @@ def resumen(etiqueta):
93
93
  return 1
94
94
  print(f' {etiqueta} ok — {HECHAS} checks\n')
95
95
  return 0
96
+
97
+
98
+ def roster(*agentes):
99
+ """The roster shape the card writers take, from `(name, kind, role)` tuples.
100
+
101
+ Written once here because two suites build the same fixture, and a fixture
102
+ copied per file is how the tests stop agreeing with each other.
103
+ """
104
+ import card
105
+
106
+ fuera = []
107
+ for entrada in agentes:
108
+ nombre, clase, rol = (list(entrada) + ['code', 'blank'])[:3]
109
+ fuera.append({
110
+ 'nombre': nombre,
111
+ 'slug': card.ventana(nombre),
112
+ 'clase': clase,
113
+ 'rol': rol,
114
+ 'mounts': [],
115
+ 'motor': {},
116
+ 'skills': [],
117
+ })
118
+ return fuera
package/bin/update ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Install the newest published version, or ask whether there is one.
3
+ exec python3 "$(cd "$(dirname "$0")/../plugin/scripts" && pwd)/actualiza.py" "$@"