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
@@ -2,24 +2,30 @@
2
2
  """Sit down to work. One command, no account, no setup.
3
3
 
4
4
  ./bin/seat open the selected city (creates `home` first time)
5
- ./bin/seat --repos choose your folders again
6
- ./bin/seat --agent-roles choose what role each repo agent adopts
5
+ ./bin/seat --agents build the roster again, agent by agent (--repos too)
7
6
  ./bin/seat --goal set your goal again
8
- ./bin/seat --engines what runs in each window: a model, or another CLI
7
+ ./bin/seat --engines what runs in your own chair: a model, or another CLI
9
8
  ./bin/seat --domain software change the city's work domain
10
9
  ./bin/seat --role dev set your role without being asked
11
10
  ./bin/seat --city home open one specific city
12
11
  ./bin/seat --no-yolo repo windows with confirmations on
13
- ./bin/seat --only a,b just these folders this time
12
+ ./bin/seat --only a,b just these agents this time
14
13
 
15
- Five questions the first time — the work domain, your chair role inside it, your
16
- folders and each repo agent's role, one goal, and what runs in each window. Enter
17
- keeps everything on your own Claude; a window can run a picked model, or another
18
- CLI entirely — Codex, OpenCode, Kimi, or an explicit terminal fallback.
19
- Then a tmux session: one window per folder you answer for, each with its assigned
20
- operating role and agent already running, and a `seat` window holding the chair
21
- role. Every repo runtime receives chaired assignments through one local bus and
22
- never addresses another repo runtime directly.
14
+ Seven questions the first time — the work domain, your chair role inside it, the
15
+ city's agents, one goal, what runs in your own chair, whether it asks permission,
16
+ and whether the city goes on your desktop.
17
+
18
+ The third question is the city itself, and it is a loop rather than a list: an
19
+ agent is asked for in full — its name, the kind of work it does, its role,
20
+ everything it works on, the engine and effort that run it, and the skills it
21
+ starts with — and then you are asked for another, until you say the city is
22
+ complete. An agent is not a repository: it may answer for three services and a
23
+ folder of documents at once, or for documents alone with no git anywhere.
24
+
25
+ Then a tmux session: one window per agent, each with its assigned operating role
26
+ and engine already running, and a `seat` window holding the chair role. Every
27
+ agent runtime receives chaired assignments through one local bus and never
28
+ addresses another agent runtime directly.
23
29
 
24
30
  It installs what it needs rather than telling you to: tmux, the city plugin, and
25
31
  the separate GitHub CLI `gh` **only if** you choose to read your folders from
@@ -47,6 +53,7 @@ import sys
47
53
 
48
54
  GUIONES = os.path.dirname(os.path.abspath(__file__))
49
55
  sys.path.insert(0, GUIONES)
56
+ import busca # noqa: E402 the one disk scanner: repos, worktrees, documents
50
57
  import card # noqa: E402 a card: read, written
51
58
  import gh # noqa: E402 who you are, and what GitHub knows
52
59
  import roles # noqa: E402 the catalogue and the bus suffixes
@@ -55,6 +62,8 @@ import ui # noqa: E402 the terminal widgets
55
62
  import units # noqa: E402 the districts file, one writer
56
63
  import cities # noqa: E402 which cities exist, and what each is called
57
64
  import parcels # noqa: E402 the houses file, one writer
65
+ import workspace # noqa: E402 agents, their workspaces, mounts and skills
66
+ import atajos # noqa: E402 the city's door on the desktop
58
67
 
59
68
  # Where the clone is, when there is one. The session script and the map live in it;
60
69
  # neither is needed to open a seat, and a seat opened from an installed plugin has no
@@ -77,7 +86,7 @@ hay = gh.hay
77
86
 
78
87
  # One progress contract for the whole first-run flow. Nested source/clone screens
79
88
  # remain inside the folders step rather than pretending they are extra decisions.
80
- PASOS = 6
89
+ PASOS = 7
81
90
 
82
91
 
83
92
  def instala(programa, formulas):
@@ -274,17 +283,11 @@ def donde_viven_las_fichas(usuario=""):
274
283
  def repos_del_disco():
275
284
  """Every repo cloned on this machine, by the name on its remote.
276
285
 
277
- find-repos.sh already does this and caches it for a day, which matters: the
278
- first crawl of a full home directory is not fast, and nobody wants to wait for
279
- it twice.
286
+ `busca` already does this and caches it for a day, which matters: the first
287
+ crawl of a full home directory is not fast, and nobody wants to wait for it
288
+ twice.
280
289
  """
281
- salida = sh([os.path.join(GUIONES, "find-repos.sh")])
282
- fuera = []
283
- for linea in salida.splitlines():
284
- if "\t" in linea:
285
- nombre, ruta = linea.split("\t", 1)
286
- fuera.append((nombre.strip(), ruta.strip()))
287
- return fuera
290
+ return busca.repos()
288
291
 
289
292
 
290
293
  def ultimo_commit(ruta):
@@ -307,22 +310,32 @@ def mios(usuario, correo, repos):
307
310
  return marcados
308
311
 
309
312
 
310
- def escribe_ficha(ruta, usuario, rol, repos, objetivo=None, ciudad="", repo_roles=None):
313
+ def escribe_ficha(ruta, usuario, rol, agentes=None, objetivo=None, ciudad=""):
311
314
  """The card. Everything the seat, a round and the map read about a person.
312
315
 
313
- The goal block comes from card.py, which the wizard also uses a round reads
314
- whichever door produced the card and must not be able to tell.
316
+ One shape: a roster of agents. `repos:` remains a card format this product
317
+ READS forever every old city keeps working but nothing writes it any
318
+ more, because two writers of one fact is how the terminal and the web ended
319
+ up producing different cities for the same city. The keys come from
320
+ `workspace.claves_de_roster`, the same ones the Hall's endpoints write.
321
+
322
+ The goal block comes from card.py, which the wizard also uses — a round
323
+ reads whichever door produced the card and must not be able to tell.
315
324
  """
316
325
  agente = roles.agente(usuario, rol, ciudad)
317
- asignados = card.normaliza_roles_repos(repos, repo_roles)
326
+ agentes = list(agentes or [])
327
+ identidad = [
328
+ f"{clave}: {valor}"
329
+ for clave, valor in workspace.claves_de_roster(agentes).items()
330
+ if valor or clave == "agents"
331
+ ]
318
332
  fm = [
319
333
  "---",
320
334
  f"user: {usuario}",
321
335
  f"name: {usuario}",
322
336
  f"role: {rol}",
323
337
  f"agent: {agente}",
324
- "repos: [" + ", ".join(sorted(repos)) + "]",
325
- *(f"role.{card.ventana(repo)}: {rol_repo}" for repo, rol_repo in asignados.items()),
338
+ *identidad,
326
339
  f"goals_defined: {'true' if objetivo else 'false'}",
327
340
  "---",
328
341
  ]
@@ -334,22 +347,21 @@ def escribe_ficha(ruta, usuario, rol, repos, objetivo=None, ciudad="", repo_role
334
347
  "",
335
348
  card.linea_rol(rol, oficio),
336
349
  "",
337
- "## Repos",
350
+ "## Agents",
338
351
  "",
339
352
  ]
340
- if repos:
341
- cuerpo += [
342
- "Answers for: " + ", ".join(f"`{r}`" for r in sorted(repos)) + ".",
343
- "",
344
- "Change them with `./bin/seat --repos`.",
345
- ]
353
+ if agentes:
354
+ for a in agentes:
355
+ monta = f"{len(a['mounts'])} mount{'s' if len(a['mounts']) != 1 else ''}"
356
+ cuerpo.append(f"- `{a['nombre']}` — {a['clase']}, role {a['rol']}, {monta}.")
357
+ cuerpo += ["", "Change the roster with `./bin/seat --agents`."]
346
358
  else:
347
359
  cuerpo += [
348
- "Owns no folders, on purpose. This role holds a property of "
360
+ "No agents yet, on purpose. This role holds a property of "
349
361
  "everybody else's — so it has no houses, and what it is for "
350
362
  "begins where there is more than one city to reach.",
351
363
  "",
352
- "If that is wrong: `./bin/seat --repos`.",
364
+ "If that is wrong: `./bin/seat --agents`.",
353
365
  ]
354
366
  cuerpo += ["", "## Current goals", ""]
355
367
 
@@ -822,7 +834,7 @@ def clona(dueno, faltan, destino):
822
834
  ultima = (r.stderr or "").strip().splitlines()
823
835
  print(f" could not: {ultima[-1] if ultima else 'unknown reason'}")
824
836
  # The index is cached for a day and has just gone stale by definition.
825
- subprocess.run([os.path.join(GUIONES, "find-repos.sh"), "--refresh"], capture_output=True)
837
+ busca.lugares(refrescar=True)
826
838
  return puestas
827
839
 
828
840
 
@@ -923,6 +935,260 @@ def elige_repos(usuario, ya_marcados):
923
935
  return elegidos, rutas
924
936
 
925
937
 
938
+ def catalogo_de_repos(usuario):
939
+ """The repo rows to offer, resolved ONCE for the whole roster.
940
+
941
+ The old wizard asked "which folders are yours?" a single time and made one
942
+ agent per tick. A roster asks per agent instead, and asking GitHub — or
943
+ re-walking the disk — once per agent would be both slow and rude. So the
944
+ source question happens once and every agent picks from the same rows.
945
+
946
+ Returns (rows, paths-by-name, github-owner) or None when the person quit.
947
+ """
948
+ locales = dict(repos_del_disco())
949
+ correo = sh(["git", "config", "user.email"]).strip()
950
+ r = resuelve_fuente()
951
+ if r is None:
952
+ return None
953
+ fuente, dueno = r
954
+ if fuente == "disco":
955
+ filas = [(n, r, ultimo_commit(r), "") for n, r in locales.items()]
956
+ else:
957
+ print(f" Asking GitHub for the repos of {dueno}…")
958
+ filas = repos_de_github(dueno, locales) or []
959
+ filas.sort(key=lambda f: f[2] or "", reverse=True)
960
+ mios_ahora = mios(usuario, correo, [(n, r) for n, r, _, _ in filas if r])
961
+ return {
962
+ "filas": filas,
963
+ "rutas": {n: r for n, r, _, _ in filas if r},
964
+ "dueno": dueno,
965
+ "mios": mios_ahora,
966
+ }
967
+
968
+
969
+ def pregunta_montajes(nombre, catalogo, sugeridos=()):
970
+ """What one agent works on: any number of repos, plus any folders.
971
+
972
+ This is the shape the old wizard could not express. An agent is not "a
973
+ repo": it may answer for three services and a folder of documents, or for
974
+ documents alone with no git anywhere. Both are asked here, and none is
975
+ mandatory — an agent may start empty and mount later.
976
+
977
+ Returns the list of mount sources (paths, or repo names not cloned yet), or
978
+ None when the person quit.
979
+ """
980
+ fuentes = []
981
+ filas = catalogo["filas"] if catalogo else []
982
+ if filas:
983
+ ops = []
984
+ for n, ruta, cuando, _ in filas:
985
+ donde = ruta.replace(os.path.expanduser("~"), "~") if ruta else "· not cloned here"
986
+ ops.append((n, n, f"{cuando or '—'} {donde}"))
987
+ elegidos = ui.elige(
988
+ f"Which repositories does {nombre} work on?",
989
+ ops,
990
+ marcadas=set(sugeridos),
991
+ minimo=0,
992
+ step=3,
993
+ of=PASOS,
994
+ ayuda="As many as this agent answers for — one, three, or none. "
995
+ "They are mounted inside its workspace, not turned into separate "
996
+ "agents. Nothing ticked is a valid answer.",
997
+ )
998
+ if elegidos is None:
999
+ return None
1000
+ rutas_repo = dict(catalogo["rutas"])
1001
+ if catalogo["dueno"]:
1002
+ faltan = [n for n in elegidos if n not in rutas_repo]
1003
+ rutas_repo.update(
1004
+ clona(
1005
+ catalogo["dueno"],
1006
+ faltan,
1007
+ os.environ.get("CITY_CODE_DIR", os.path.expanduser("~/codigo")),
1008
+ )
1009
+ )
1010
+ fuentes += [rutas_repo.get(n, n) for n in sorted(elegidos)]
1011
+
1012
+ # And the work that is not a repo at all — the reason this model exists.
1013
+ while True:
1014
+ carpeta = ui.pide(
1015
+ f"A folder of documents for {nombre} (empty when done)",
1016
+ "",
1017
+ ayuda="Anything on this disk: a handbook, a drive folder, case "
1018
+ "files. It is mounted the same way a repo is, and needs no git.",
1019
+ )
1020
+ if carpeta is None:
1021
+ return None
1022
+ carpeta = carpeta.strip()
1023
+ if not carpeta:
1024
+ return fuentes
1025
+ destino = os.path.expanduser(carpeta)
1026
+ if not os.path.isdir(destino):
1027
+ print(f" ↳ there is no folder at {destino}")
1028
+ continue
1029
+ fuentes.append(destino)
1030
+
1031
+
1032
+ def pregunta_clase(nombre):
1033
+ """What kind of work this agent does — what makes its house grow."""
1034
+ return ui.una(
1035
+ f"What kind of work does {nombre} do?",
1036
+ [
1037
+ ("code", "Code", "grows with merged pull requests; mounts repos and worktrees"),
1038
+ ("knowledge", "Knowledge", "grows with documents; needs no git at all"),
1039
+ ("coordinator", "Coordinator", "grows with recorded decisions"),
1040
+ ],
1041
+ 3,
1042
+ PASOS,
1043
+ ayuda="This is not a permission. It decides how this agent's work is "
1044
+ "counted and how its house grows on the map — shipping code, keeping "
1045
+ "knowledge, or deciding.",
1046
+ )
1047
+
1048
+
1049
+ def pregunta_skills_de_agente(datos, nombre, slug, runtime):
1050
+ """Offer to install skills into this agent's own home, for the engine that
1051
+ reads them.
1052
+
1053
+ Skills are the Claude runtime's format. Offering them for a window running
1054
+ Codex would be selling something that engine ignores, so the question is
1055
+ only asked where the answer means anything — and it says so.
1056
+ """
1057
+ if runtime and runtime != "claude":
1058
+ print(f" {nombre} runs {runtime}, which ignores skills — none offered.")
1059
+ return []
1060
+ puestas = []
1061
+ while True:
1062
+ origen = ui.pide(
1063
+ f"A skill folder or .zip for {nombre} (empty when done)",
1064
+ "",
1065
+ ayuda="Installed into this agent's own home, and read by Claude in "
1066
+ "its window. Recognition elsewhere stays read-only: this is the one "
1067
+ "place Agents City writes a skill, because you asked it to.",
1068
+ )
1069
+ if origen is None or not str(origen).strip():
1070
+ return puestas
1071
+ puesta, mal = workspace.instala_skill(datos, slug, str(origen).strip())
1072
+ if mal:
1073
+ print(f" ↳ {mal}")
1074
+ continue
1075
+ puestas.append(puesta)
1076
+ print(f" ↳ {puesta} installed for {nombre}.")
1077
+
1078
+
1079
+ def pregunta_un_agente(datos, dominio, catalogo, ya_puestos):
1080
+ """One agent, whole: who it is, what it does, what it works on, what runs
1081
+ it, and the skills it starts with. Returns the agent dict, or None on quit.
1082
+ """
1083
+ def libre(valor):
1084
+ limpio = card.ventana(str(valor or "").strip())
1085
+ if not str(valor or "").strip():
1086
+ return "an agent needs a name"
1087
+ if limpio in {card.ventana(n) for n in ya_puestos}:
1088
+ return f"{limpio} is already an agent in this city"
1089
+ return ""
1090
+
1091
+ nombre = ui.pide(
1092
+ "The agent's name",
1093
+ "",
1094
+ validar=libre,
1095
+ ayuda="How you will call it — in its tmux window, on the map, and on "
1096
+ "the bus. A person's name, a service, a team: whatever you would say "
1097
+ "out loud when you ask it for something.",
1098
+ )
1099
+ if nombre is None or not nombre.strip():
1100
+ return None
1101
+ nombre = nombre.strip()
1102
+ slug = card.ventana(nombre)
1103
+
1104
+ clase = pregunta_clase(nombre)
1105
+ if clase is None:
1106
+ return None
1107
+ rol = pregunta_rol_agente(nombre, dominio)
1108
+ if rol is None:
1109
+ return None
1110
+ montajes = pregunta_montajes(nombre, catalogo)
1111
+ if montajes is None:
1112
+ return None
1113
+ # The engine, the model and the effort, asked where they belong: on the
1114
+ # agent that will run them. They used to be a separate late step about
1115
+ # "windows", which is the old repo-shaped vocabulary.
1116
+ motor = motor_de_ventana(slug)
1117
+ if motor is None:
1118
+ return None
1119
+ workspace.crea_workspace(datos, slug)
1120
+ skills = pregunta_skills_de_agente(datos, nombre, slug, motor.get(f"runs.{slug}", ""))
1121
+ return {
1122
+ "nombre": nombre,
1123
+ "slug": slug,
1124
+ "clase": clase,
1125
+ "rol": rol,
1126
+ "mounts": montajes,
1127
+ "motor": motor,
1128
+ "skills": skills,
1129
+ }
1130
+
1131
+
1132
+ def pregunta_agentes(usuario, dominio, datos, actuales=()):
1133
+ """The roster, built one agent at a time until the city is complete.
1134
+
1135
+ The city is not "your folders". It is the people in it, and each of them is
1136
+ asked for in full — name, kind, role, everything it works on, its engine and
1137
+ its skills — before the next one is offered. The loop ends when you say the
1138
+ city is complete, which is the only thing that ends it.
1139
+ """
1140
+ agentes = list(actuales)
1141
+ catalogo = None
1142
+ while True:
1143
+ if agentes:
1144
+ hecho = ui.una(
1145
+ f"{len(agentes)} agent{'s' if len(agentes) != 1 else ''} so far: "
1146
+ + ", ".join(a["nombre"] for a in agentes),
1147
+ [
1148
+ ("otro", "Add another agent", "one more member of this city"),
1149
+ ("fin", "That is the whole city", "the roster is complete"),
1150
+ ],
1151
+ 3,
1152
+ PASOS,
1153
+ ayuda="A city keeps working as you add to it — this is not your "
1154
+ "last chance. Change the roster any time with "
1155
+ "./bin/seat --agents.",
1156
+ )
1157
+ if hecho is None:
1158
+ return None
1159
+ if hecho != "otro":
1160
+ return agentes
1161
+ elif not ui.pantalla(
1162
+ "Now the people in this city",
1163
+ "A city is its agents, and each one is asked for in full: who it "
1164
+ "is, what kind of work it does, its role, everything it works on, "
1165
+ "the engine that runs it and the skills it starts with.\n\n"
1166
+ "An agent is not a repository. It can answer for three services "
1167
+ "and a folder of documents at once, or for documents alone with no "
1168
+ "git anywhere — that is why this asks agent by agent instead of "
1169
+ "handing you a list of folders to tick.\n\n"
1170
+ "You will be asked for another after each one, until you say the "
1171
+ "city is complete. None is a valid answer too: a seat with no "
1172
+ "agents is a city that reaches others over roads.",
1173
+ 3,
1174
+ PASOS,
1175
+ "enter to add the first · q for a city with no agents yet",
1176
+ ):
1177
+ return agentes
1178
+ if catalogo is None:
1179
+ catalogo = catalogo_de_repos(usuario)
1180
+ if catalogo is None:
1181
+ return None
1182
+ nuevo = pregunta_un_agente(datos, dominio, catalogo, [a["nombre"] for a in agentes])
1183
+ if nuevo is None:
1184
+ # Backing out of one agent leaves the roster as it was, rather than
1185
+ # throwing away everybody who was already described.
1186
+ if not agentes:
1187
+ return agentes
1188
+ continue
1189
+ agentes.append(nuevo)
1190
+
1191
+
926
1192
  def dominio_configurado(a, nueva, datos):
927
1193
  """Resolve question one, including the explicit CLI non-interactive path."""
928
1194
  dominio_actual = domains.de_ciudad(datos)
@@ -940,7 +1206,7 @@ def rol_configurado(a, nueva, dominio, actual=""):
940
1206
  """Resolve question two; a domain switch always asks for a matching role."""
941
1207
  if a.role:
942
1208
  return a.role
943
- solo_otro_ajuste = a.goal or a.repos or a.agent_roles or a.engines
1209
+ solo_otro_ajuste = a.goal or a.agent_roles or a.engines
944
1210
  if nueva or a.domain or not solo_otro_ajuste:
945
1211
  return pregunta_rol(dominio, actual)
946
1212
  return actual or domains.roles_de(dominio)[0][0]
@@ -978,18 +1244,58 @@ def pregunta_seat_yolo(datos):
978
1244
  return True
979
1245
 
980
1246
 
1247
+ def pregunta_atajo(datos, nueva):
1248
+ """Question seven: whether this city gets a door on the desktop.
1249
+
1250
+ A tool you have to remember how to start is a tool you stop starting. The
1251
+ shortcut runs the same command a person would type, so it adds a way in
1252
+ without adding a second implementation of anything. Only offered where a
1253
+ desktop exists, and only on a new city — nobody wants this asked weekly.
1254
+ """
1255
+ if not nueva or not atajos.escritorio():
1256
+ return True
1257
+ eleccion = ui.una(
1258
+ "Put this city on your desktop?",
1259
+ [
1260
+ (
1261
+ "si",
1262
+ "Yes, with its icon",
1263
+ "double-click it and the city opens, tmux session and all",
1264
+ ),
1265
+ ("no", "No thanks", "you can add it later: agents-city shortcut"),
1266
+ ],
1267
+ 7,
1268
+ PASOS,
1269
+ ayuda="A real desktop shortcut carrying the city's name and a colour "
1270
+ "drawn from its own identity. It runs `agents-city seat --city …` — "
1271
+ "the same line you would type. Remove it any time: "
1272
+ "agents-city shortcut --remove.",
1273
+ )
1274
+ if eleccion is None:
1275
+ return False
1276
+ if eleccion == "si":
1277
+ ruta, mal = atajos.crea(datos)
1278
+ print(f" {mal}" if mal else f" On your desktop: {ruta}")
1279
+ return True
1280
+
1281
+
981
1282
  def hay_ajustes(a, nueva):
982
- """Whether this invocation re-asks any of the six questions."""
983
- return bool(nueva or a.repos or a.agent_roles or a.goal or a.domain or a.role or a.engines)
1283
+ """Whether this invocation re-asks any of the seven questions."""
1284
+ return bool(nueva or a.agent_roles or a.goal or a.domain or a.role or a.engines)
1285
+
984
1286
 
1287
+ def pregunta_runtime(a, nueva, datos):
1288
+ """Questions five and six: what runs in the chair's own window, and whether
1289
+ it asks permission. None means the person quit; {} means neither question
1290
+ was due this invocation.
985
1291
 
986
- def pregunta_runtime(a, nueva, elegidos, datos):
987
- """Questions five and six together: what runs in each window, and whether
988
- the chair itself asks permission. None means the person quit; {} means
989
- neither question was due this invocation."""
1292
+ Every other window's engine belongs to its agent and is asked while that
1293
+ agent is being created a late "what runs in each window?" pass was the
1294
+ old repo-shaped vocabulary talking.
1295
+ """
990
1296
  if not (nueva or a.engines):
991
1297
  return {}
992
- motores = pregunta_motores(elegidos)
1298
+ motores = pregunta_motores([])
993
1299
  if motores is None or not pregunta_seat_yolo(datos):
994
1300
  return None
995
1301
  return motores
@@ -1010,7 +1316,7 @@ def ajusta_seat_yolo(a, datos, nueva):
1010
1316
 
1011
1317
 
1012
1318
  def configura(a, ficha, nueva, datos, usuario):
1013
- """The six questions, or the setting being re-asked. False means quit."""
1319
+ """The seven questions, or the setting being re-asked. False means quit."""
1014
1320
  previo = open(ficha).read() if not nueva else ""
1015
1321
 
1016
1322
  def campo(k, defecto=""):
@@ -1025,25 +1331,14 @@ def configura(a, ficha, nueva, datos, usuario):
1025
1331
  if not rol:
1026
1332
  return False
1027
1333
 
1028
- # 3. The folders.
1029
- if nueva or a.repos:
1030
- resultado = elige_repos(usuario, [] if nueva else repos_de_ficha(ficha))
1031
- if resultado is None:
1032
- return False
1033
- elegidos, _ = resultado
1034
- else:
1035
- elegidos = repos_de_ficha(ficha)
1036
-
1037
- # Every concrete repo agent has a specialty distinct from the seat's chair
1038
- # role. Legacy cards resolve missing assignments to the honest blank role.
1039
- actuales_roles = card.lee(ficha).get("repo_roles", {}) if not nueva else {}
1040
- cambia_roles_repos = nueva or a.repos or a.agent_roles
1041
- if cambia_roles_repos:
1042
- roles_repos = pregunta_roles_repos(elegidos, dominio, actuales_roles)
1043
- if roles_repos is None:
1334
+ # 3. The roster, one agent at a time — each asked for in full.
1335
+ plantilla = nueva or a.agent_roles
1336
+ if plantilla:
1337
+ roster = pregunta_agentes(usuario, dominio, datos, agentes_de_ficha(ficha, datos))
1338
+ if roster is None:
1044
1339
  return False
1045
1340
  else:
1046
- roles_repos = card.normaliza_roles_repos(elegidos, actuales_roles)
1341
+ roster = agentes_de_ficha(ficha, datos)
1047
1342
 
1048
1343
  # 4. The goal, which is the one thing a round cannot work without.
1049
1344
  objetivo = None
@@ -1052,11 +1347,16 @@ def configura(a, ficha, nueva, datos, usuario):
1052
1347
  elif campo("goals_defined") == "true":
1053
1348
  objetivo = card.objetivo(previo, usuario)
1054
1349
 
1055
- # 5 and 6. The engines, and whether the chair itself asks permission.
1056
- motores = pregunta_runtime(a, nueva, elegidos, datos)
1350
+ # 5 and 6. The chair's own engine, and whether it asks permission. Every
1351
+ # other engine was decided on its own agent, where it belongs.
1352
+ motores = pregunta_runtime(a, nueva, datos)
1057
1353
  if motores is None:
1058
1354
  return False
1059
1355
 
1356
+ # 7. The door on the desktop, offered once, when the city is new.
1357
+ if not pregunta_atajo(datos, nueva):
1358
+ return False
1359
+
1060
1360
  escribe_cambios(
1061
1361
  ficha,
1062
1362
  nueva,
@@ -1064,17 +1364,57 @@ def configura(a, ficha, nueva, datos, usuario):
1064
1364
  usuario,
1065
1365
  dominio,
1066
1366
  rol,
1067
- elegidos,
1068
- roles_repos,
1367
+ roster,
1069
1368
  objetivo,
1070
1369
  a.goal,
1071
1370
  motores,
1072
- cambia_roles_repos,
1371
+ plantilla,
1073
1372
  )
1074
- resume(datos, usuario, dominio, rol, elegidos, roles_repos, objetivo)
1373
+ resume(datos, usuario, dominio, rol, roster, objetivo)
1075
1374
  return True
1076
1375
 
1077
1376
 
1377
+ def agentes_de_ficha(ficha, datos):
1378
+ """The roster already on a card, in the wizard's own shape.
1379
+
1380
+ Reads through `workspace.agentes`, so a legacy `repos:` card arrives as the
1381
+ agents it always was — one per repo, each mounting itself — and re-running
1382
+ the wizard over it upgrades rather than discards.
1383
+ """
1384
+ try:
1385
+ texto = card.lee(ficha).get("texto") or ""
1386
+ except OSError:
1387
+ return []
1388
+ try:
1389
+ vivos = workspace.agentes(texto, datos)
1390
+ except ValueError:
1391
+ return []
1392
+ return [workspace.como_ficha(a) for a in vivos]
1393
+
1394
+
1395
+ # What a roster looks like on a card is workspace.py's fact, not the wizard's:
1396
+ # the Hall writes the same keys from a POST, and two writers of one shape drift.
1397
+ claves_de_agentes = workspace.claves_de_roster
1398
+
1399
+
1400
+ def materializa_agentes(datos, ficha):
1401
+ """Give every agent on the card its workspace and its mounts on disk.
1402
+
1403
+ The card is the source of truth, so this reads it back rather than trusting
1404
+ the answers in memory: what the launcher and the cage will see is exactly
1405
+ what gets built here."""
1406
+ try:
1407
+ texto = card.lee(ficha).get("texto") or ""
1408
+ vivos = workspace.agentes(texto, datos)
1409
+ except (OSError, ValueError):
1410
+ return []
1411
+ hechos = []
1412
+ for a in vivos:
1413
+ destinos = workspace.sincroniza(a, datos)
1414
+ hechos.append((a.nombre, len(destinos)))
1415
+ return hechos
1416
+
1417
+
1078
1418
  def escribe_cambios(
1079
1419
  ficha,
1080
1420
  nueva,
@@ -1082,26 +1422,17 @@ def escribe_cambios(
1082
1422
  usuario,
1083
1423
  dominio,
1084
1424
  rol,
1085
- elegidos,
1086
- roles_repos,
1425
+ roster,
1087
1426
  objetivo,
1088
1427
  con_goal,
1089
1428
  motores,
1090
- actualiza_roles_repos,
1429
+ actualiza_roster,
1091
1430
  ):
1092
1431
  """Land the answers on the card — the whole template for a new one, surgery
1093
1432
  for one that exists, because an existing card may carry round history and
1094
1433
  changing your role must not erase what happened."""
1095
1434
  if nueva:
1096
- escribe_ficha(
1097
- ficha,
1098
- usuario,
1099
- rol,
1100
- elegidos,
1101
- objetivo,
1102
- cities.slug_ciudad(datos),
1103
- roles_repos,
1104
- )
1435
+ escribe_ficha(ficha, usuario, rol, roster, objetivo, cities.slug_ciudad(datos))
1105
1436
  else:
1106
1437
  card.cambia_rol(
1107
1438
  ficha,
@@ -1109,51 +1440,59 @@ def escribe_cambios(
1109
1440
  roles.agente(usuario, rol, cities.slug_ciudad(datos)),
1110
1441
  roles.oficio(rol, datos),
1111
1442
  )
1112
- card.cambia_repos(ficha, elegidos)
1113
- if actualiza_roles_repos:
1114
- card.cambia_roles_repos(ficha, elegidos, roles_repos)
1115
1443
  if con_goal or objetivo is not None:
1116
1444
  card.cambia_objetivo(ficha, objetivo)
1117
1445
  # After the template or the surgery, either way: pon_campo is itself surgical.
1446
+ if actualiza_roster or nueva:
1447
+ for clave, valor in claves_de_agentes(roster).items():
1448
+ card.pon_campo(ficha, clave, valor)
1118
1449
  for clave, valor in motores.items():
1119
1450
  card.pon_campo(ficha, clave, valor)
1451
+ materializa_agentes(datos, ficha)
1120
1452
  domains.selecciona(datos, dominio)
1121
1453
  conocimiento = domains.materializa(datos, dominio, rol)
1122
- for rol_repo in sorted(set(roles_repos.values()) - {"blank", rol}):
1123
- conocimiento += domains.materializa(datos, dominio, rol_repo)
1454
+ for rol_agente in sorted({a["rol"] for a in roster} - {"blank", rol}):
1455
+ conocimiento += domains.materializa(datos, dominio, rol_agente)
1124
1456
  if conocimiento:
1125
1457
  print(" Domain knowledge installed as editable files: " + ", ".join(conocimiento) + ".")
1126
1458
  otros = sorted(k.split(".", 1)[1] for k, v in motores.items() if v and k.startswith("runs."))
1459
+ otros += sorted(
1460
+ a["slug"] for a in roster if (a.get("motor") or {}).get(f"runs.{a['slug']}")
1461
+ )
1127
1462
  if otros:
1128
1463
  print(f" Windows on another engine: {', '.join(otros)} — the rest run Claude.")
1129
1464
 
1130
1465
 
1131
- def resume(datos, usuario, dominio, rol, elegidos, roles_repos, objetivo):
1466
+ def resume(datos, usuario, dominio, rol, roster, objetivo):
1132
1467
  """Say what was written, and what to do with it."""
1133
1468
  print(f" City address: {cities.direccion(usuario, datos)}.")
1134
1469
  pack = domains.obtiene(dominio)
1135
1470
  print(f" Domain: {pack['name'] if pack else dominio}.")
1136
1471
  print(f" Your role there: {roles.nombre(rol)}.")
1137
- if elegidos:
1472
+ if roster:
1138
1473
  print(
1139
- f" {len(elegidos)} folders on your card one window each. "
1140
- f"Change them with ./bin/seat --repos"
1141
- )
1142
- resumen_roles = ", ".join(
1143
- f"{repo}: {roles.nombre(roles_repos.get(repo, 'blank'))}" for repo in sorted(elegidos)
1474
+ f" {len(roster)} agent{'s' if len(roster) != 1 else ''} in this city — "
1475
+ f"one window each. Change the roster with ./bin/seat --agents"
1144
1476
  )
1145
- print(f" Support-agent roles: {resumen_roles}.")
1477
+ for a in roster:
1478
+ trabajo = (
1479
+ f"{len(a['mounts'])} mount{'s' if len(a['mounts']) != 1 else ''}"
1480
+ if a["mounts"]
1481
+ else "nothing mounted yet"
1482
+ )
1483
+ extra = f", skills: {', '.join(a['skills'])}" if a.get("skills") else ""
1484
+ print(f" · {a['nombre']} — {a['clase']}, {roles.nombre(a['rol'])}, {trabajo}{extra}")
1146
1485
  else:
1147
1486
  print(
1148
- " No support repos in this city. Its seat still opens on its own;\n"
1487
+ " No agents in this city yet. Its seat still opens on its own;\n"
1149
1488
  " roads can connect it to other local or remote cities."
1150
1489
  )
1151
1490
  print(f" Goal: {objetivo['title'] if objetivo else 'none yet — ./bin/seat --goal'}")
1152
1491
  # Only in our own folder: a real data repo's modelling is not ours to touch.
1153
1492
  if cities.gestionada(datos, usuario):
1154
- for h in escribe_suelo(datos, elegidos, dominio):
1493
+ for h in escribe_suelo(datos, [a["nombre"] for a in roster], dominio):
1155
1494
  print(f" · {h}")
1156
- if elegidos:
1495
+ if roster:
1157
1496
  print(
1158
1497
  f"\n Your city is drawable now — no wizard, no account:\n"
1159
1498
  f" ./bin/city {datos.replace(os.path.expanduser('~'), '~')}"
@@ -1211,13 +1550,16 @@ def argumentos():
1211
1550
  door's job is the order of the steps, not the shape of the arguments."""
1212
1551
  ap = argparse.ArgumentParser(add_help=False)
1213
1552
  ap.add_argument("usuario", nargs="?", default="")
1214
- ap.add_argument("--repos", action="store_true", help="pick your repos again")
1553
+ # One question, one flag. `--repos` and `--agent-roles` are the names this
1554
+ # asked by when a repo WAS an agent; they still work, and they open the same
1555
+ # roster, because breaking somebody's muscle memory buys nothing.
1215
1556
  ap.add_argument(
1216
- "--agent-roles",
1217
1557
  "--agents",
1558
+ "--agent-roles",
1559
+ "--repos",
1218
1560
  action="store_true",
1219
1561
  dest="agent_roles",
1220
- help="choose the operating role each repo agent adopts",
1562
+ help="build the roster again, agent by agent (--repos is the old name for it)",
1221
1563
  )
1222
1564
  ap.add_argument("--goal", action="store_true", help="set your goal again")
1223
1565
  ap.add_argument(
@@ -1314,7 +1656,7 @@ def main():
1314
1656
  if hay_ajustes(a, nueva):
1315
1657
  if nueva:
1316
1658
  print(
1317
- f"\n First time here. Six questions and you are in — the work "
1659
+ f"\n First time here. Seven questions and you are in — the work "
1318
1660
  f"domain, your chair role,\n your folders and their agent roles, "
1319
1661
  f"one goal, what runs in each window, "
1320
1662
  f"and whether\n your own chair asks permission. "