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
|
@@ -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
|
-
("
|
|
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, "
|
|
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
|
|
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"
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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'
|
|
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
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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()
|