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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +310 -70
- package/README.md +297 -69
- package/bin/agents-city.js +3 -0
- package/bin/doctor +3 -0
- package/bin/hall.html +164 -24
- package/bin/navegador.mjs +415 -0
- package/bin/serve.py +383 -127
- package/bin/shortcut +3 -0
- package/bin/test +5 -2
- package/bin/test-actualiza.py +130 -0
- package/bin/test-atajos.py +301 -0
- package/bin/test-busca.py +216 -0
- package/bin/test-cage.py +170 -2
- package/bin/test-card.py +2 -2
- package/bin/test-cities.py +45 -0
- package/bin/test-contracts.py +12 -5
- package/bin/test-doctor.py +33 -0
- package/bin/test-navegador.py +164 -0
- package/bin/test-seat.py +245 -25
- package/bin/test-serve.py +214 -9
- package/bin/test-workspace.py +63 -0
- package/bin/testlib.py +23 -0
- package/bin/update +3 -0
- package/city/web/dist/city.js +47 -47
- package/city/web/dist/index.html +1 -1
- package/city/web/dist-hall/hall.js +2193 -174
- package/city/web/src/bienvenida.ts +686 -0
- package/city/web/src/es.ts +180 -0
- package/city/web/src/hall.ts +520 -168
- package/city/web/src/idioma.ts +86 -0
- package/city/web/src/main.ts +27 -0
- package/city/web/src/motores.ts +54 -0
- package/docs/agents-first.md +8 -1
- package/docs/security.md +46 -12
- package/docs/testing.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/bus.js +1 -1
- package/plugin/channel/bus.ts +1 -1
- package/plugin/channel/runtime/codex.ts +1 -1
- package/plugin/channel/runtime-gateway.js +1 -1
- package/plugin/scripts/actualiza.py +198 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/busca.py +436 -0
- package/plugin/scripts/cage.py +266 -26
- package/plugin/scripts/capabilities.py +17 -10
- package/plugin/scripts/card.py +10 -0
- package/plugin/scripts/cities.py +34 -0
- package/plugin/scripts/city-session.sh +33 -7
- package/plugin/scripts/doctor.py +122 -0
- package/plugin/scripts/find-repos.sh +12 -105
- package/plugin/scripts/read-card.py +6 -2
- package/plugin/scripts/report.py +5 -6
- package/plugin/scripts/reset.py +50 -14
- package/plugin/scripts/seat.py +445 -103
- package/plugin/scripts/workspace.py +197 -0
package/plugin/scripts/seat.py
CHANGED
|
@@ -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 --
|
|
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
|
|
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
|
|
12
|
+
./bin/seat --only a,b just these agents this time
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 =
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"##
|
|
350
|
+
"## Agents",
|
|
338
351
|
"",
|
|
339
352
|
]
|
|
340
|
-
if
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
""
|
|
344
|
-
|
|
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
|
-
"
|
|
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 --
|
|
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
|
-
|
|
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.
|
|
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
|
|
983
|
-
return bool(nueva or a.
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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(
|
|
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
|
|
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
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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
|
|
1056
|
-
|
|
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
|
-
|
|
1068
|
-
roles_repos,
|
|
1367
|
+
roster,
|
|
1069
1368
|
objetivo,
|
|
1070
1369
|
a.goal,
|
|
1071
1370
|
motores,
|
|
1072
|
-
|
|
1371
|
+
plantilla,
|
|
1073
1372
|
)
|
|
1074
|
-
resume(datos, usuario, dominio, rol,
|
|
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
|
-
|
|
1086
|
-
roles_repos,
|
|
1425
|
+
roster,
|
|
1087
1426
|
objetivo,
|
|
1088
1427
|
con_goal,
|
|
1089
1428
|
motores,
|
|
1090
|
-
|
|
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
|
|
1123
|
-
conocimiento += domains.materializa(datos, dominio,
|
|
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,
|
|
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
|
|
1472
|
+
if roster:
|
|
1138
1473
|
print(
|
|
1139
|
-
f" {len(
|
|
1140
|
-
f"Change
|
|
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
|
-
|
|
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
|
|
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,
|
|
1493
|
+
for h in escribe_suelo(datos, [a["nombre"] for a in roster], dominio):
|
|
1155
1494
|
print(f" · {h}")
|
|
1156
|
-
if
|
|
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
|
-
|
|
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="
|
|
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.
|
|
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. "
|