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,164 @@
1
+ #!/usr/bin/env python3
2
+ """The Hall, in a real browser that clicks.
3
+
4
+ ./bin/test-navegador.py
5
+
6
+ Every other suite in this repo can pass while the page is dead. The instruction
7
+ editors and the skill upload once shipped rendered, typechecked, and wired to
8
+ nothing at all: `tsc` cannot see a handler that was never attached, and a
9
+ DOM-free assertion cannot click. This suite is the answer to that class of bug,
10
+ and it exists because that bug shipped.
11
+
12
+ It serves a real Hall over loopback with a throwaway city behind it, drives
13
+ headless Chrome over the DevTools protocol (`bin/navegador.mjs`, no driver
14
+ library), and asserts that the controls DO something. Without a browser on the
15
+ machine it says so and passes; CI sets CITY_BROWSER_REQUIRED=1 so a runner that
16
+ lost its Chrome fails loudly instead of quietly testing nothing.
17
+ """
18
+
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import threading
25
+
26
+ AQUI = os.path.dirname(os.path.abspath(__file__))
27
+ RAIZ = os.path.dirname(AQUI)
28
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
29
+ sys.path.insert(0, AQUI)
30
+
31
+ import serve # noqa: E402
32
+ from testlib import afirma, resumen # noqa: E402
33
+
34
+
35
+ def disco_de_prueba():
36
+ """A small disk with one folder of documents on it.
37
+
38
+ The mount picker is only testable against something: with an empty search
39
+ root the chips are empty either way, and a search box that filters nothing
40
+ down to nothing would pass a test while being useless.
41
+ """
42
+ raiz = tempfile.mkdtemp()
43
+ boveda = os.path.join(raiz, "manual")
44
+ os.makedirs(boveda)
45
+ for n in ("uno.md", "dos.md", "tres.md"):
46
+ open(os.path.join(boveda, n), "w").close()
47
+ return raiz
48
+
49
+
50
+ def ciudad_de_prueba(datos):
51
+ """A city with one agent in it, so the sheets have something to draw."""
52
+ with open(os.path.join(datos, "city.yml"), "w", encoding="utf-8") as f:
53
+ f.write("id: city_browser_v1\nname: Browser Test\nslug: browser\nowner: navtest\n")
54
+ taller = os.path.join(datos, "agents", "notas")
55
+ os.makedirs(taller, exist_ok=True)
56
+ with open(os.path.join(taller, "apuntes.md"), "w", encoding="utf-8") as f:
57
+ f.write("# notas\n")
58
+ with open(os.path.join(datos, "navtest.md"), "w", encoding="utf-8") as f:
59
+ f.write(
60
+ "---\n"
61
+ "user: navtest\nname: Nav Test\nrole: cpto\nagent: navtest/seat\n"
62
+ "agents: [notas]\nkind.notas: knowledge\nrole.notas: apuntes\n"
63
+ "goals_defined: false\n---\n\n# navtest\n"
64
+ )
65
+
66
+
67
+ def bundle_al_dia():
68
+ """Rebuild the Hall bundle when this machine can, so the browser drives the
69
+ CURRENT source.
70
+
71
+ The bundles are committed so an install needs no build step, which means a
72
+ stale one is exactly what a browser test would happily pass against — the
73
+ bug it exists to catch, one level down. Where esbuild is not installed the
74
+ committed bundle is what ships, and testing that is honest too.
75
+ """
76
+ web = os.path.join(RAIZ, "city", "web")
77
+ if not os.path.isdir(os.path.join(web, "node_modules", "esbuild")):
78
+ return "committed bundle (no esbuild here)"
79
+ hecho = subprocess.run(
80
+ [
81
+ os.path.join(web, "node_modules", ".bin", "esbuild"),
82
+ os.path.join(web, "src", "hall.ts"),
83
+ "--bundle",
84
+ "--format=esm",
85
+ "--outfile=" + os.path.join(web, "dist-hall", "hall.js"),
86
+ ],
87
+ capture_output=True,
88
+ text=True,
89
+ timeout=180,
90
+ )
91
+ if hecho.returncode != 0:
92
+ return "committed bundle (build failed)"
93
+ return "rebuilt from src/hall.ts"
94
+
95
+
96
+ def main():
97
+ print()
98
+ print(" the hall, in a browser that clicks")
99
+ print(f" · {bundle_al_dia()}")
100
+ datos = tempfile.mkdtemp()
101
+ casa = tempfile.mkdtemp()
102
+ serve.cities.REGISTRO = os.path.join(tempfile.mkdtemp(), "cities")
103
+ previo = {k: os.environ.get(k) for k in ("AGENTS_CITY_DATA", "AGENTS_CITY_HOME",
104
+ "AGENTS_CITY_USER", "CITY_SEARCH_IN",
105
+ "XDG_CACHE_HOME")}
106
+ os.environ.update(
107
+ AGENTS_CITY_DATA=datos,
108
+ AGENTS_CITY_HOME=casa,
109
+ AGENTS_CITY_USER="navtest",
110
+ CITY_SEARCH_IN=disco_de_prueba(),
111
+ XDG_CACHE_HOME=tempfile.mkdtemp(),
112
+ )
113
+ ciudad_de_prueba(datos)
114
+
115
+ # A SECOND city, deliberately empty: the guide only appears where there is
116
+ # nothing yet, and the first fixture has an agent so the sheets can render.
117
+ try:
118
+ vacia = serve.cities.crea("navtest", "sin-nadie", usar=False)
119
+ except (OSError, ValueError):
120
+ vacia = ""
121
+
122
+ servidor = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
123
+ hilo = threading.Thread(target=servidor.serve_forever, daemon=True)
124
+ hilo.start()
125
+ url = f"http://127.0.0.1:{servidor.server_port}/?PASE={serve.PASE}"
126
+ try:
127
+ salida = subprocess.run(
128
+ ["node", os.path.join(AQUI, "navegador.mjs"), url, vacia],
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=300,
132
+ )
133
+ texto = (salida.stdout or "") + (salida.stderr or "")
134
+ if "skipped" in texto and salida.returncode == 0:
135
+ print(" no browser on this machine — the clicking checks were skipped")
136
+ print("\n navegador ok — 0 checks (no browser on this machine)\n")
137
+ return 0
138
+ # Each browser check counts as one here: a suite that reports "1 check"
139
+ # for eleven assertions hides both its coverage and which one broke.
140
+ vistas = 0
141
+ for linea in texto.splitlines():
142
+ limpia = linea.strip()
143
+ if limpia.startswith("ok ·") or limpia.startswith("FAIL·"):
144
+ vistas += 1
145
+ afirma("· " + limpia.split("·", 1)[1].strip(), limpia.startswith("ok"), "")
146
+ if not vistas:
147
+ afirma("· the browser checks ran at all", False, texto.strip()[-500:])
148
+ elif salida.returncode != 0:
149
+ afirma("· the browser driver finished cleanly", False, texto.strip()[-300:])
150
+ finally:
151
+ servidor.shutdown()
152
+ servidor.server_close()
153
+ for k, v in previo.items():
154
+ if v is None:
155
+ os.environ.pop(k, None)
156
+ else:
157
+ os.environ[k] = v
158
+ shutil.rmtree(datos, ignore_errors=True)
159
+ shutil.rmtree(casa, ignore_errors=True)
160
+ return resumen("navegador")
161
+
162
+
163
+ if __name__ == "__main__":
164
+ sys.exit(main())
package/bin/test-seat.py CHANGED
@@ -25,6 +25,7 @@ is said when a machine cannot do this at all.
25
25
  import importlib.machinery as mach
26
26
  import importlib.util as iu
27
27
  import base64
28
+ import json
28
29
  import os
29
30
  import shlex
30
31
  import shutil
@@ -37,7 +38,7 @@ RAIZ = os.path.dirname(AQUI)
37
38
  sys.path.insert(0, AQUI)
38
39
  sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
39
40
 
40
- from testlib import comprueba, afirma, resumen # noqa: E402
41
+ from testlib import comprueba, afirma, resumen, roster # noqa: E402
41
42
 
42
43
 
43
44
  def carga_seat():
@@ -95,11 +96,7 @@ def camino_feliz():
95
96
  os.makedirs(datos)
96
97
  ficha = os.path.join(datos, "ana.md")
97
98
  S.escribe_ficha(
98
- ficha,
99
- "ana",
100
- "cpto",
101
- ["api", "web"],
102
- repo_roles={"api": "data-engineer", "web": "seo"},
99
+ ficha, "ana", "cpto", roster(("api", "code", "data-engineer"), ("web", "code", "seo"))
103
100
  )
104
101
 
105
102
  texto = open(ficha).read()
@@ -108,7 +105,8 @@ def camino_feliz():
108
105
  ("user", "ana"),
109
106
  ("role", "cpto"),
110
107
  ("agent", "ana/lead"),
111
- ("repos", "[api, web]"),
108
+ ("agents", "[api, web]"),
109
+ ("kind.api", "code"),
112
110
  ("role.api", "data-engineer"),
113
111
  ("role.web", "seo"),
114
112
  ):
@@ -120,11 +118,23 @@ def camino_feliz():
120
118
 
121
119
  # The one field the session actually reads, read the way the session reads it.
122
120
  leido = subprocess.run(
123
- ["python3", os.path.join(RAIZ, "plugin", "scripts", "read-card.py"), ficha, "repos"],
121
+ ["python3", os.path.join(RAIZ, "plugin", "scripts", "read-card.py"), ficha, "agents"],
124
122
  capture_output=True,
125
123
  text=True,
126
124
  ).stdout.strip()
127
- comprueba("· read-card.py gets the repos back out", leido, "api,web")
125
+ comprueba("· read-card.py gets the roster back out", leido, "api,web")
126
+ # And the shape nothing writes any more is still READ, forever: an old city
127
+ # on somebody's disk keeps opening exactly as it did.
128
+ vieja = os.path.join(datos, "vieja.md")
129
+ with open(vieja, "w", encoding="utf-8") as f:
130
+ f.write("---\nuser: leg\nagent: leg/lead\nrepos: [api, web]\nrole.api: dev\n---\n")
131
+ heredado = subprocess.run(
132
+ ["python3", os.path.join(RAIZ, "plugin", "scripts", "read-card.py"), vieja, "repos"],
133
+ capture_output=True,
134
+ text=True,
135
+ ).stdout.strip()
136
+ comprueba("· a legacy repos: card is still read, though nothing writes one",
137
+ heredado, "api,web")
128
138
  agente = subprocess.run(
129
139
  ["python3", os.path.join(RAIZ, "plugin", "scripts", "read-card.py"), ficha, "agent"],
130
140
  capture_output=True,
@@ -337,7 +347,7 @@ def caminos_infelices():
337
347
  # could keep up with every vendor's flags.
338
348
  mixto = tempfile.mkdtemp()
339
349
  fmix = os.path.join(mixto, "zzmix.md")
340
- S.escribe_ficha(fmix, "zzmix", "dev", [], None)
350
+ S.escribe_ficha(fmix, "zzmix", "dev")
341
351
  import card as _c2
342
352
 
343
353
  afirma(
@@ -509,7 +519,9 @@ def maquinas_hostiles():
509
519
  "· an old cached Claude plugin is updated through Claude's own CLI",
510
520
  llamadas
511
521
  and llamadas[0][:4] == ["claude", "plugin", "update", "city@agents-city"]
512
- and "0.3.0-beta.21" in salida.getvalue(),
522
+ # The version comes from package.json, not from a literal here: a
523
+ # release must never mean editing an assertion in a test.
524
+ and version_del_paquete() in salida.getvalue(),
513
525
  f"calls={llamadas!r} output={salida.getvalue()!r}",
514
526
  )
515
527
 
@@ -673,7 +685,7 @@ def puesto_completo():
673
685
  "by": "this quarter",
674
686
  }
675
687
  ficha = os.path.join(casa, "ana.md")
676
- S.escribe_ficha(ficha, "ana", "cpto", ["api"], meta)
688
+ S.escribe_ficha(ficha, "ana", "cpto", roster(("api",)), meta)
677
689
  texto = open(ficha).read()
678
690
  afirma("· a card with a goal says so in its frontmatter", "goals_defined: true" in texto)
679
691
  vuelta = card.objetivo(texto, "ana")
@@ -683,7 +695,7 @@ def puesto_completo():
683
695
  # A goal with no command must come back as no command, not as the prose that
684
696
  # stands in for one on the page.
685
697
  sin = dict(meta, command="")
686
- S.escribe_ficha(ficha, "ana", "cpto", ["api"], sin)
698
+ S.escribe_ficha(ficha, "ana", "cpto", roster(("api",)), sin)
687
699
  afirma(
688
700
  '· "manual" is shown to a reader but is not read back as a command',
689
701
  card.objetivo(open(ficha).read(), "ana")["command"] == "",
@@ -691,7 +703,7 @@ def puesto_completo():
691
703
  )
692
704
 
693
705
  # No goal at all.
694
- S.escribe_ficha(ficha, "ana", "cpto", ["api"], None)
706
+ S.escribe_ficha(ficha, "ana", "cpto", roster(("api",)), None)
695
707
  texto = open(ficha).read()
696
708
  afirma("· no goal says goals_defined: false", "goals_defined: false" in texto)
697
709
  comprueba("· and reads back as no goal", card.objetivo(texto, "ana"), None)
@@ -888,7 +900,7 @@ import importlib.machinery as m, importlib.util as u
888
900
  s = u.spec_from_loader('seat', m.SourceFileLoader('seat', {seat_aislado!r}))
889
901
  mod = u.module_from_spec(s); s.loader.exec_module(mod)
890
902
  ficha = {os.path.join(jaula, "datos", "ana.md")!r}
891
- mod.escribe_ficha(ficha, 'ana', 'cpto', [], None)
903
+ mod.escribe_ficha(ficha, 'ana', 'cpto')
892
904
  print(open(ficha).read())
893
905
  """)
894
906
  os.makedirs(os.path.join(jaula, "datos"), exist_ok=True)
@@ -1150,11 +1162,7 @@ def arranque_escalonado():
1150
1162
  os.makedirs(d)
1151
1163
  ficha = os.path.join(datos, "ana.md")
1152
1164
  S.escribe_ficha(
1153
- ficha,
1154
- "ana",
1155
- "dev",
1156
- ["api", "docs"],
1157
- repo_roles={"api": "data-engineer", "docs": "seo"},
1165
+ ficha, "ana", "dev", roster(("api", "code", "data-engineer"), ("docs", "code", "seo"))
1158
1166
  )
1159
1167
  card.pon_campo(ficha, "runs.docs", "codex --yolo")
1160
1168
 
@@ -1299,11 +1307,25 @@ def arranque_escalonado():
1299
1307
  and "adapter.js" not in docs,
1300
1308
  docs[:300],
1301
1309
  )
1302
- afirma(
1303
- Codex avoids an outer seatbelt so its MCPs can use one native sandbox",
1304
- "sandbox-exec" not in docs and "CITY_OUTER_CAGE=1" not in docs,
1305
- docs[:500],
1306
- )
1310
+ # The Codex exemption is a SEATBELT constraint, not a fact about Codex: the
1311
+ # macOS kernel refuses a nested sandbox_apply, and bubblewrap does not. So
1312
+ # the contract differs by kernel, and this suite decodes a REAL launcher —
1313
+ # asserting the macOS shape everywhere is how a Linux Codex window ended up
1314
+ # able to read ~/.ssh while every other window in the city had it sealed.
1315
+ if sys.platform == "darwin":
1316
+ afirma(
1317
+ "· on macOS Codex avoids the outer seatbelt so its MCPs keep one native sandbox",
1318
+ "sandbox-exec" not in docs and "CITY_OUTER_CAGE=1" not in docs,
1319
+ docs[:500],
1320
+ )
1321
+ else:
1322
+ import cage as _cage
1323
+
1324
+ afirma(
1325
+ "· off macOS Codex is caged like every other runtime, when a cage exists",
1326
+ ("bwrap" in docs) == _cage.disponible(),
1327
+ f"disponible={_cage.disponible()} :: {docs[:400]}",
1328
+ )
1307
1329
  afirma(
1308
1330
  "· and it does not consume a turn either, so no gap is wasted",
1309
1331
  "sleep 9" not in " ".join(lineas),
@@ -1369,11 +1391,209 @@ def arranque_escalonado():
1369
1391
  shutil.rmtree(casa)
1370
1392
 
1371
1393
 
1394
+ # ══ the roster: a city is its agents, asked for one at a time ══════════════
1395
+ def _mundo_de_prueba():
1396
+ """A disk with two repos, a folder of documents and one installable skill."""
1397
+ base = tempfile.mkdtemp(prefix="agents-city-roster-")
1398
+ datos = os.path.join(base, "city")
1399
+ os.makedirs(datos)
1400
+ for nombre in ("api", "web", "handbook"):
1401
+ os.makedirs(os.path.join(base, nombre))
1402
+ skill = os.path.join(base, "triage-skill")
1403
+ os.makedirs(skill)
1404
+ with open(os.path.join(skill, "SKILL.md"), "w") as f:
1405
+ f.write("---\nname: triage\n---\nhow triage is done here\n")
1406
+ catalogo = {
1407
+ "filas": [("api", os.path.join(base, "api"), "", ""),
1408
+ ("web", os.path.join(base, "web"), "", "")],
1409
+ "rutas": {"api": os.path.join(base, "api"), "web": os.path.join(base, "web")},
1410
+ "dueno": "",
1411
+ "mios": set(),
1412
+ }
1413
+ return base, datos, catalogo, skill
1414
+
1415
+
1416
+ def _guion(unas, pides, elige=()):
1417
+ """Script the three widgets the roster asks through."""
1418
+ it_una, it_pide = iter(unas), iter(pides)
1419
+ return {
1420
+ "una": lambda *a, **k: next(it_una),
1421
+ "pide": lambda *a, **k: next(it_pide),
1422
+ "elige": lambda *a, **k: list(elige),
1423
+ "pantalla": lambda *a, **k: True,
1424
+ }
1425
+
1426
+
1427
+ def plantilla_de_agentes():
1428
+ print(" the roster: one agent at a time, each asked for in full")
1429
+ base, datos, catalogo, skill = _mundo_de_prueba()
1430
+ docs = os.path.join(base, "handbook")
1431
+ try:
1432
+ # One agent that answers for TWO repos AND a folder of documents — the
1433
+ # shape the old "tick your folders" step could not express at all.
1434
+ guion = _guion(
1435
+ unas=["knowledge", "triage", "claude", "fin"],
1436
+ pides=["urgencias", docs, "", "haiku", "low", skill, ""],
1437
+ elige=["api", "web"],
1438
+ )
1439
+ with finge(ui, **guion), finge(S, catalogo_de_repos=lambda u: catalogo):
1440
+ roster = S.pregunta_agentes("ana", "healthcare", datos)
1441
+ uno = roster[0] if roster else {}
1442
+ afirma(
1443
+ "· one agent can mount several repos and a document folder at once",
1444
+ len(roster) == 1 and sorted(os.path.basename(m) for m in uno["mounts"])
1445
+ == ["api", "handbook", "web"],
1446
+ str(uno.get("mounts")),
1447
+ )
1448
+ comprueba("· its kind is asked, not assumed to be code", uno.get("clase"), "knowledge")
1449
+ comprueba("· its role is its own, not the seat's", uno.get("rol"), "triage")
1450
+ comprueba(
1451
+ "· its model and effort are asked on the agent itself",
1452
+ {k: v for k, v in uno["motor"].items() if v},
1453
+ {"model.urgencias": "haiku", "effort.urgencias": "low"},
1454
+ )
1455
+ comprueba("· and the skills it starts with are installed there", uno["skills"],
1456
+ ["triage-skill"])
1457
+ instalada = os.path.join(
1458
+ S.workspace.workspace_de(datos, "urgencias"), ".claude", "skills", "triage-skill"
1459
+ )
1460
+ afirma("· the skill really lands in that agent's own home",
1461
+ os.path.isfile(os.path.join(instalada, "SKILL.md")), instalada)
1462
+
1463
+ # The loop is the point: it keeps offering another until you end it.
1464
+ guion = _guion(
1465
+ unas=["code", "dev", "claude", "otro", "knowledge", "blank", "claude", "fin"],
1466
+ pides=["nova", "", "", "", "prensa", docs, "", "", ""],
1467
+ elige=[],
1468
+ )
1469
+ with finge(ui, **guion), finge(S, catalogo_de_repos=lambda u: catalogo):
1470
+ dos = S.pregunta_agentes("ana", "software", datos)
1471
+ comprueba("· 'add another agent' keeps asking until the city is complete",
1472
+ [a["nombre"] for a in dos], ["nova", "prensa"])
1473
+
1474
+ # An empty city is a real answer: a seat that reaches others over roads.
1475
+ with finge(ui, **_guion(unas=[], pides=[], elige=[])), finge(
1476
+ ui, pantalla=lambda *a, **k: False
1477
+ ):
1478
+ vacio = S.pregunta_agentes("ana", "software", datos)
1479
+ comprueba("· a city with no agents is a valid answer, not a dead end", vacio, [])
1480
+ finally:
1481
+ shutil.rmtree(base, ignore_errors=True)
1482
+
1483
+
1484
+ def ficha_de_agentes():
1485
+ print(" the roster on the card, and on disk")
1486
+ base, datos, catalogo, _ = _mundo_de_prueba()
1487
+ ficha = os.path.join(datos, "ana.md")
1488
+ try:
1489
+ mio = [
1490
+ {"nombre": "urgencias", "slug": "urgencias", "clase": "knowledge", "rol": "triage",
1491
+ "mounts": [os.path.join(base, "handbook"), os.path.join(base, "api")],
1492
+ "motor": {"runs.urgencias": "", "model.urgencias": "haiku",
1493
+ "effort.urgencias": "low"},
1494
+ "skills": []},
1495
+ ]
1496
+ S.escribe_ficha(ficha, "ana", "cpto", mio, None, "city")
1497
+ texto = open(ficha, encoding="utf-8").read()
1498
+ afirma(
1499
+ "· a new card is written agents-first, with no repos: list at all",
1500
+ "agents: [urgencias]" in texto
1501
+ and "kind.urgencias: knowledge" in texto
1502
+ and "role.urgencias: triage" in texto
1503
+ and "mounts.urgencias: [" in texto
1504
+ and "repos:" not in texto,
1505
+ texto.split("---")[1],
1506
+ )
1507
+ comprueba(
1508
+ "· every engine answer becomes the card key the launcher resolves",
1509
+ S.claves_de_agentes(mio)["model.urgencias"],
1510
+ "haiku",
1511
+ )
1512
+ S.materializa_agentes(datos, ficha)
1513
+ enlaces = [e for e, _ in S.workspace.mounts_en_disco(datos, "urgencias")]
1514
+ comprueba("· and every mount is a real symlink in that agent's workspace",
1515
+ sorted(enlaces), ["api", "handbook"])
1516
+
1517
+ # The upgrade path: a legacy repos: card re-enters the roster as the
1518
+ # agents it always was, so re-running the wizard adds instead of losing.
1519
+ vieja = os.path.join(datos, "leg.md")
1520
+ with open(vieja, "w", encoding="utf-8") as f:
1521
+ f.write("---\nuser: leg\nname: leg\nrole: cpto\nagent: leg/city\n"
1522
+ "repos: [api, web]\nrole.api: dev\ngoals_defined: false\n---\n")
1523
+ previo = S.agentes_de_ficha(vieja, datos)
1524
+ afirma(
1525
+ "· a legacy repos: card is read back as the agents it always was",
1526
+ [a["nombre"] for a in previo] == ["api", "web"]
1527
+ and previo[0]["clase"] == "code"
1528
+ and previo[0]["rol"] == "dev",
1529
+ str(previo),
1530
+ )
1531
+ finally:
1532
+ shutil.rmtree(base, ignore_errors=True)
1533
+
1534
+
1535
+ def agentes_no_felices():
1536
+ print(" the roster's unhappy paths")
1537
+ base, datos, catalogo, _ = _mundo_de_prueba()
1538
+ try:
1539
+ # Two agents that slug to one identity would silently share a window,
1540
+ # a workspace and a bus actor. The name question refuses it up front.
1541
+ vistos = []
1542
+
1543
+ def pide_nombre(pregunta, defecto="", validar=None, ayuda=""):
1544
+ if validar and "name" in pregunta:
1545
+ vistos.append(validar("urgencias"))
1546
+ return "otra"
1547
+ return ""
1548
+
1549
+ # With one agent already there, the FIRST question is the add-another
1550
+ # menu — the roster loop's own gate, before any question about a new one.
1551
+ guion = _guion(unas=["otro", "knowledge", "blank", "claude", "fin"], pides=[])
1552
+ guion["pide"] = pide_nombre
1553
+ with finge(ui, **guion), finge(S, catalogo_de_repos=lambda u: catalogo):
1554
+ S.pregunta_agentes(
1555
+ "ana", "software", datos,
1556
+ [{"nombre": "urgencias", "slug": "urgencias", "clase": "code", "rol": "blank",
1557
+ "mounts": [], "motor": {}, "skills": []}],
1558
+ )
1559
+ afirma("· a second agent cannot take an existing agent's identity",
1560
+ vistos and "already an agent" in vistos[0], str(vistos))
1561
+
1562
+ # A folder that is not there is a retry, never a mount pointing nowhere.
1563
+ avisos = []
1564
+ respuestas = iter(["/nope/nothing/here", ""])
1565
+ with finge(ui, pide=lambda *a, **k: next(respuestas),
1566
+ elige=lambda *a, **k: [], una=lambda *a, **k: "claude"):
1567
+ montajes = S.pregunta_montajes("nova", {"filas": [], "rutas": {}, "dueno": "",
1568
+ "mios": set()})
1569
+ comprueba("· a folder that does not exist is refused, not mounted", montajes, [])
1570
+ del avisos
1571
+
1572
+ # Skills are the Claude runtime's format; offering them for an engine
1573
+ # that ignores them would be selling something that does nothing.
1574
+ with finge(ui, pide=lambda *a, **k: "/should/never/be/asked"):
1575
+ comprueba(
1576
+ "· an agent on another engine is not offered skills it would ignore",
1577
+ S.pregunta_skills_de_agente(datos, "nova", "nova", "codex --full-auto"),
1578
+ [],
1579
+ )
1580
+ finally:
1581
+ shutil.rmtree(base, ignore_errors=True)
1582
+
1583
+
1584
+ def version_del_paquete():
1585
+ with open(os.path.join(RAIZ, "package.json"), encoding="utf-8") as f:
1586
+ return json.load(f)["version"]
1587
+
1588
+
1372
1589
  def main():
1373
1590
  print()
1374
1591
  camino_feliz()
1375
1592
  dos_puertas()
1376
1593
  puesto_completo()
1594
+ plantilla_de_agentes()
1595
+ ficha_de_agentes()
1596
+ agentes_no_felices()
1377
1597
  motores_del_puesto()
1378
1598
  arranque_escalonado()
1379
1599
  caminos_infelices()