agents-city 0.3.0 → 0.4.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 +54 -5
- package/README.md +53 -5
- package/bin/agents-city.js +4 -0
- package/bin/hall.html +92 -5
- package/bin/navegador.mjs +141 -12
- package/bin/serve.py +94 -38
- package/bin/test +6 -3
- package/bin/test-demo.py +67 -0
- package/bin/test-desinstala.py +170 -0
- package/bin/test-navegador.py +2 -0
- package/bin/test-serve.py +122 -2
- package/bin/uninstall +5 -0
- package/city/web/dist/city.js +65 -65
- package/city/web/dist/index.html +18 -5
- package/city/web/dist-hall/hall.js +941 -385
- package/city/web/index.html +17 -4
- package/city/web/src/bienvenida.ts +35 -329
- package/city/web/src/casa.ts +282 -0
- package/city/web/src/demo.ts +247 -0
- package/city/web/src/dialogo.ts +178 -0
- package/city/web/src/es.ts +96 -0
- package/city/web/src/explorador.ts +189 -0
- package/city/web/src/hall.ts +157 -54
- package/city/web/src/main.ts +28 -5
- package/demo/graba.py +132 -0
- package/demo/grabaciones/legal.jsonl +22 -0
- package/demo/grabaciones/medicina.jsonl +22 -0
- package/demo/grabaciones/software.jsonl +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/scripts/demos.py +107 -0
- package/plugin/scripts/desinstala.py +222 -0
package/bin/serve.py
CHANGED
|
@@ -27,7 +27,6 @@ import socketserver
|
|
|
27
27
|
import subprocess
|
|
28
28
|
import sys
|
|
29
29
|
import threading
|
|
30
|
-
import time
|
|
31
30
|
import urllib.parse
|
|
32
31
|
import webbrowser
|
|
33
32
|
|
|
@@ -36,13 +35,13 @@ sys.path.insert(0, AQUI)
|
|
|
36
35
|
import setup as W # noqa: E402 legacy template catalogue used by the Hall
|
|
37
36
|
|
|
38
37
|
sys.path.insert(0, os.path.join(os.path.dirname(AQUI), "plugin", "scripts"))
|
|
39
|
-
import busca # noqa: E402 the one disk scanner: repos, worktrees, documents
|
|
40
38
|
import card # noqa: E402
|
|
41
39
|
import domains # noqa: E402
|
|
42
40
|
import gh # noqa: E402
|
|
43
41
|
import parcels # noqa: E402
|
|
44
42
|
import units # noqa: E402
|
|
45
43
|
import cities # noqa: E402
|
|
44
|
+
import demos # noqa: E402 the recorded demos the Hall plays back
|
|
46
45
|
import roads # noqa: E402
|
|
47
46
|
import capabilities # noqa: E402
|
|
48
47
|
import deliberations # noqa: E402
|
|
@@ -129,7 +128,7 @@ def contador_git_local(a, datos):
|
|
|
129
128
|
plain commits, activity the last thirty days. Without this, `_code`
|
|
130
129
|
honestly answers zero and every code agent's sheet showed an empty house
|
|
131
130
|
no matter how much it had built. The disk index a legacy agent needs is
|
|
132
|
-
memoised inside capabilities, so N agents cost one
|
|
131
|
+
memoised inside capabilities, so N agents cost one disk scan.
|
|
133
132
|
"""
|
|
134
133
|
import time
|
|
135
134
|
|
|
@@ -522,12 +521,15 @@ def actividad_viva(datos):
|
|
|
522
521
|
return {"online": False, "url": "", "city": "", "started_at": ""}
|
|
523
522
|
|
|
524
523
|
|
|
525
|
-
def
|
|
526
|
-
"""
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
524
|
+
def _que_es_git(ruta):
|
|
525
|
+
"""`repo`, `worktree`, or nothing. A label on a row, never a filter: the
|
|
526
|
+
picker shows every folder and lets the person decide which one matters."""
|
|
527
|
+
marca = os.path.join(ruta, ".git")
|
|
528
|
+
if os.path.isdir(marca):
|
|
529
|
+
return "repo"
|
|
530
|
+
if os.path.isfile(marca):
|
|
531
|
+
return "worktree"
|
|
532
|
+
return ""
|
|
531
533
|
|
|
532
534
|
|
|
533
535
|
def carpetas_probables():
|
|
@@ -699,7 +701,8 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
699
701
|
"/api/estado": "g_estado",
|
|
700
702
|
"/api/instrucciones": "g_instrucciones",
|
|
701
703
|
"/api/live": "g_live",
|
|
702
|
-
"/api/
|
|
704
|
+
"/api/carpeta": "g_carpeta",
|
|
705
|
+
"/api/demos": "g_demos",
|
|
703
706
|
"/api/domains": "g_domains",
|
|
704
707
|
"/api/roles": "g_roles",
|
|
705
708
|
}
|
|
@@ -852,38 +855,88 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
852
855
|
def g_live(self, q):
|
|
853
856
|
return self.responde(actividad_viva(self.ciudad(q)))
|
|
854
857
|
|
|
855
|
-
def
|
|
856
|
-
"""
|
|
858
|
+
def g_demos(self, q):
|
|
859
|
+
"""The recorded demos, and one of them in full when asked by name.
|
|
857
860
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
+
These are recordings, not simulations: `demo/graba.py` plays each story
|
|
862
|
+
over the real local bus and keeps the exact stream a spectator saw, so
|
|
863
|
+
the Hall can replay a committee without a provider account and without
|
|
864
|
+
the browser owning a second, drifting copy of the state machine.
|
|
865
|
+
"""
|
|
866
|
+
cual = q.get("story", [""])[0]
|
|
867
|
+
catalogo = demos.catalogo()
|
|
868
|
+
if not cual:
|
|
869
|
+
return self.responde({"demos": catalogo})
|
|
870
|
+
historia = next((d for d in catalogo if d["id"] == cual), None)
|
|
871
|
+
if not historia:
|
|
872
|
+
return self.responde({"error": "no such demo"}, codigo=404)
|
|
873
|
+
eventos = demos.eventos(cual)
|
|
874
|
+
if not eventos:
|
|
875
|
+
return self.responde(
|
|
876
|
+
{"error": "that demo has no recording; run demo/graba.py"}, codigo=409
|
|
877
|
+
)
|
|
878
|
+
return self.responde({**historia, "eventos": eventos})
|
|
861
879
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
880
|
+
def g_carpeta(self, q):
|
|
881
|
+
"""One folder, listed: what is in it, and what each thing is.
|
|
882
|
+
|
|
883
|
+
This replaced a picker that scanned the whole disk and offered what it
|
|
884
|
+
thought you wanted. It was wrong to do that. A person choosing what an
|
|
885
|
+
agent works on knows where their work is, and being handed a guessed
|
|
886
|
+
list of two hundred repositories is not help — it is a second thing to
|
|
887
|
+
read before you can do the thing you came to do. So: a folder, its
|
|
888
|
+
contents, and you walk.
|
|
889
|
+
|
|
890
|
+
Read-only, and metadata only. It never opens a file. The Hall is already
|
|
891
|
+
bound to the loopback behind a per-run token, and mounting an arbitrary
|
|
892
|
+
path was always allowed from here; listing one is strictly less.
|
|
867
893
|
"""
|
|
868
|
-
|
|
869
|
-
|
|
894
|
+
pedida = q.get("path", ["~"])[0] or "~"
|
|
895
|
+
ruta = os.path.realpath(os.path.expanduser(pedida))
|
|
896
|
+
if not os.path.isdir(ruta):
|
|
897
|
+
return self.responde({"error": f"there is no folder at {ruta}"}, codigo=404)
|
|
898
|
+
ocultos = bool(q.get("hidden", [""])[0])
|
|
899
|
+
entradas = []
|
|
870
900
|
try:
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
901
|
+
with os.scandir(ruta) as it:
|
|
902
|
+
for e in it:
|
|
903
|
+
if e.name.startswith(".") and not ocultos:
|
|
904
|
+
continue
|
|
905
|
+
try:
|
|
906
|
+
es_dir = e.is_dir(follow_symlinks=True)
|
|
907
|
+
except OSError:
|
|
908
|
+
es_dir = False
|
|
909
|
+
entradas.append(
|
|
910
|
+
{
|
|
911
|
+
"nombre": e.name,
|
|
912
|
+
"ruta": os.path.join(ruta, e.name),
|
|
913
|
+
"dir": es_dir,
|
|
914
|
+
# What kind of place this is, said plainly rather
|
|
915
|
+
# than used to filter anything out: a folder is
|
|
916
|
+
# yours to pick whether or not it holds a .git.
|
|
917
|
+
"git": _que_es_git(os.path.join(ruta, e.name)) if es_dir else "",
|
|
918
|
+
"enlace": e.is_symlink(),
|
|
919
|
+
}
|
|
920
|
+
)
|
|
921
|
+
except OSError as error:
|
|
922
|
+
return self.responde({"error": str(error)}, codigo=403)
|
|
923
|
+
# Folders first, then files, each alphabetical and case-insensitive —
|
|
924
|
+
# the order every file picker has used for forty years.
|
|
925
|
+
entradas.sort(key=lambda x: (not x["dir"], x["nombre"].lower()))
|
|
926
|
+
casa = os.path.expanduser("~")
|
|
927
|
+
arriba = os.path.dirname(ruta)
|
|
875
928
|
return self.responde(
|
|
876
929
|
{
|
|
877
|
-
"
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
930
|
+
"ruta": ruta,
|
|
931
|
+
"arriba": arriba if arriba != ruta else "",
|
|
932
|
+
"casa": casa,
|
|
933
|
+
"atajos": [
|
|
934
|
+
{"nombre": os.path.basename(c) or c, "ruta": c}
|
|
935
|
+
for c in [casa] + [os.path.join(casa, n) for n in CANDIDATAS]
|
|
936
|
+
if os.path.isdir(c)
|
|
937
|
+
],
|
|
938
|
+
"entradas": entradas[:2000],
|
|
939
|
+
"recortada": len(entradas) > 2000,
|
|
887
940
|
}
|
|
888
941
|
)
|
|
889
942
|
|
|
@@ -1326,8 +1379,11 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
1326
1379
|
]
|
|
1327
1380
|
elif anadir:
|
|
1328
1381
|
destino = os.path.realpath(os.path.expanduser(anadir))
|
|
1329
|
-
|
|
1330
|
-
|
|
1382
|
+
# A folder, or one exact file. Both are things somebody legitimately
|
|
1383
|
+
# wants an agent to work on — a handbook is a folder, a spec is a
|
|
1384
|
+
# file — and the workspace links either the same way.
|
|
1385
|
+
if not os.path.exists(destino):
|
|
1386
|
+
return self.responde({"error": f"there is nothing at {destino}"}, codigo=400)
|
|
1331
1387
|
try:
|
|
1332
1388
|
workspace.monta(datos, a.slug, destino)
|
|
1333
1389
|
except (OSError, ValueError) as e:
|
package/bin/test
CHANGED
|
@@ -22,7 +22,7 @@ if [ -d .git ] && [ -x .githooks/pre-commit ] \
|
|
|
22
22
|
&& printf ' pre-commit gate armed (core.hooksPath .githooks)\n'
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
|
-
SUITES=(widgets card parcels domains busca serve seat cities channel committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza launch)
|
|
25
|
+
SUITES=(widgets card parcels domains busca serve seat cities channel committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch)
|
|
26
26
|
[ $# -gt 0 ] && SUITES=("$@")
|
|
27
27
|
|
|
28
28
|
fallos=0
|
|
@@ -89,7 +89,8 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
|
|
|
89
89
|
"plugin/scripts/hall_protocol.py", "plugin/scripts/doctor.py",
|
|
90
90
|
"plugin/scripts/workspace.py", "plugin/scripts/avatar.py",
|
|
91
91
|
"plugin/scripts/crecimiento.py",
|
|
92
|
-
"plugin/scripts/reset.py", "plugin/scripts/
|
|
92
|
+
"plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
|
|
93
|
+
"plugin/scripts/demos.py", "plugin/scripts/capabilities.py",
|
|
93
94
|
"plugin/scripts/deliberations.py",
|
|
94
95
|
"plugin/scripts/runtime_processes.py",
|
|
95
96
|
"plugin/scripts/runtime_log.py", "plugin/scripts/logs.py",
|
|
@@ -102,7 +103,9 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
|
|
|
102
103
|
"plugin/channel/runtime-gateway.js",
|
|
103
104
|
"plugin/.mcp.json",
|
|
104
105
|
"bin/cities", "bin/road", "bin/bus", "bin/committee", "bin/agents",
|
|
105
|
-
"bin/reset", "bin/skills", "bin/benchmark", "bin/logs",
|
|
106
|
+
"bin/reset", "bin/uninstall", "bin/skills", "bin/benchmark", "bin/logs",
|
|
107
|
+
"demo/graba.py", "demo/grabaciones/software.jsonl",
|
|
108
|
+
"demo/grabaciones/medicina.jsonl", "demo/grabaciones/legal.jsonl",
|
|
106
109
|
"benchmarks/committee/run.py",
|
|
107
110
|
"benchmarks/latency/live.py", "benchmarks/latency/README.md",
|
|
108
111
|
"benchmarks/latency/fake-native-server.mjs",
|
package/bin/test-demo.py
CHANGED
|
@@ -86,6 +86,72 @@ def ordered(actual, expected):
|
|
|
86
86
|
return cursor == len(expected)
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def grabaciones():
|
|
90
|
+
"""The recordings the Hall plays back must still be the story they claim.
|
|
91
|
+
|
|
92
|
+
They are made by running the real thing (`demo/graba.py`), which means they
|
|
93
|
+
can go stale silently: somebody edits a story, the terminal demo changes,
|
|
94
|
+
and the browser keeps playing last month's committee as if it were current.
|
|
95
|
+
This is the check that turns that into a failure instead of a lie.
|
|
96
|
+
"""
|
|
97
|
+
print(" the recordings the Hall plays back")
|
|
98
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
99
|
+
sys.path.insert(0, os.path.join(RAIZ, "demo"))
|
|
100
|
+
import demos # noqa: PLC0415
|
|
101
|
+
from stories import STORIES # noqa: PLC0415
|
|
102
|
+
|
|
103
|
+
catalogo = demos.catalogo()
|
|
104
|
+
ids = [d["id"] for d in catalogo]
|
|
105
|
+
afirma("· every story has a recording", sorted(ids) == sorted(STORIES), str(ids))
|
|
106
|
+
for ficha in catalogo:
|
|
107
|
+
eventos = demos.eventos(ficha["id"])
|
|
108
|
+
historia = STORIES[ficha["id"]]
|
|
109
|
+
afirma(
|
|
110
|
+
f"· {ficha['id']}: the recording says what the story says",
|
|
111
|
+
ficha["titulo"] == historia["title"], f"{ficha['titulo']} != {historia['title']}",
|
|
112
|
+
)
|
|
113
|
+
afirma(
|
|
114
|
+
f"· {ficha['id']}: it opens with the story's own question",
|
|
115
|
+
eventos[0]["kind"] == "committee.opened"
|
|
116
|
+
and eventos[0]["summary"] == historia["turns"][0]["payload"]["question"],
|
|
117
|
+
str(eventos[0])[:200],
|
|
118
|
+
)
|
|
119
|
+
afirma(
|
|
120
|
+
f"· {ficha['id']}: and ends with the committee closed",
|
|
121
|
+
eventos[-1]["kind"] == "committee.closed", str(eventos[-1])[:200],
|
|
122
|
+
)
|
|
123
|
+
afirma(
|
|
124
|
+
f"· {ficha['id']}: every invited specialist speaks",
|
|
125
|
+
all(
|
|
126
|
+
quien in ficha["reparto"]
|
|
127
|
+
for quien in historia["turns"][0]["payload"]["participants"]
|
|
128
|
+
),
|
|
129
|
+
f"{ficha['reparto']} vs {historia['turns'][0]['payload']['participants']}",
|
|
130
|
+
)
|
|
131
|
+
afirma(
|
|
132
|
+
f"· {ficha['id']}: the turns are in order and numbered",
|
|
133
|
+
[e["seq"] for e in eventos] == list(range(1, len(eventos) + 1)),
|
|
134
|
+
str([e["seq"] for e in eventos]),
|
|
135
|
+
)
|
|
136
|
+
# A recording that carried ids, timestamps or a thread would replay a
|
|
137
|
+
# dead city's identifiers into somebody else's Hall.
|
|
138
|
+
afirma(
|
|
139
|
+
f"· {ficha['id']}: nothing volatile was recorded",
|
|
140
|
+
not any(
|
|
141
|
+
clave in e for e in eventos for clave in ("id", "at", "city", "thread", "sourceId")
|
|
142
|
+
),
|
|
143
|
+
str(eventos[0]),
|
|
144
|
+
)
|
|
145
|
+
afirma(
|
|
146
|
+
f"· {ficha['id']}: and no raw payload leaked into it",
|
|
147
|
+
all(set(e) <= {
|
|
148
|
+
"protocol", "seq", "kind", "actor", "role", "phase", "tone",
|
|
149
|
+
"title", "summary", "details", "target",
|
|
150
|
+
} for e in eventos),
|
|
151
|
+
str(sorted({k for e in eventos for k in e})),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
89
155
|
def main():
|
|
90
156
|
global FIXTURE
|
|
91
157
|
print("\n full Aurora demo on the real local bus")
|
|
@@ -301,6 +367,7 @@ def main():
|
|
|
301
367
|
stop_hubs(app)
|
|
302
368
|
shutil.rmtree(base, ignore_errors=True)
|
|
303
369
|
|
|
370
|
+
grabaciones()
|
|
304
371
|
return resumen("demo")
|
|
305
372
|
|
|
306
373
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Getting the product off a machine, without taking anything else with it.
|
|
3
|
+
|
|
4
|
+
One assertion here matters more than the rest put together: an agent's home is
|
|
5
|
+
full of symlinks into real repositories and real folders of documents, and the
|
|
6
|
+
uninstaller walks straight through it. If it ever followed one of those links,
|
|
7
|
+
it would delete somebody's work. That is the check this file exists for.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
16
|
+
RAIZ = os.path.dirname(AQUI)
|
|
17
|
+
sys.path.insert(0, AQUI)
|
|
18
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
19
|
+
|
|
20
|
+
import cities # noqa: E402
|
|
21
|
+
import desinstala # noqa: E402
|
|
22
|
+
from testlib import afirma, comprueba, resumen # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def monta():
|
|
26
|
+
"""A machine with the product on it, and one repository beside it."""
|
|
27
|
+
casa = tempfile.mkdtemp()
|
|
28
|
+
trabajo = tempfile.mkdtemp()
|
|
29
|
+
|
|
30
|
+
repo = os.path.join(trabajo, "el-repo")
|
|
31
|
+
os.makedirs(os.path.join(repo, "src"))
|
|
32
|
+
with open(os.path.join(repo, "src", "importante.txt"), "w") as f:
|
|
33
|
+
# Deliberately big: the size check below has to be able to tell "walked
|
|
34
|
+
# into the repository" apart from "the product wrote a few more bytes
|
|
35
|
+
# than this fixture did", and a 17-byte file cannot carry that.
|
|
36
|
+
f.write("this must survive\n" + "x" * 400_000)
|
|
37
|
+
|
|
38
|
+
docs = os.path.join(trabajo, "manual")
|
|
39
|
+
os.makedirs(docs)
|
|
40
|
+
with open(os.path.join(docs, "uno.md"), "w") as f:
|
|
41
|
+
f.write("so must this")
|
|
42
|
+
|
|
43
|
+
ciudad = os.path.join(casa, "agents-city", "yo", "home")
|
|
44
|
+
agente = os.path.join(ciudad, "agents", "api")
|
|
45
|
+
os.makedirs(agente)
|
|
46
|
+
with open(os.path.join(ciudad, "city.yml"), "w") as f:
|
|
47
|
+
f.write("owner: yo\nname: home\n")
|
|
48
|
+
# The two links an agent's home is made of.
|
|
49
|
+
os.symlink(repo, os.path.join(agente, "el-repo"))
|
|
50
|
+
os.symlink(docs, os.path.join(agente, "manual"))
|
|
51
|
+
with open(os.path.join(agente, "CLAUDE.md"), "w") as f:
|
|
52
|
+
f.write("mine")
|
|
53
|
+
return casa, trabajo, repo, docs, ciudad
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def enlaces(casa, trabajo, repo, docs, ciudad):
|
|
57
|
+
print(" what it must never follow")
|
|
58
|
+
raiz = os.path.join(casa, "agents-city")
|
|
59
|
+
afirma("· the fixture really does link out of the city",
|
|
60
|
+
os.path.islink(os.path.join(ciudad, "agents", "api", "el-repo")), "")
|
|
61
|
+
|
|
62
|
+
tam = desinstala._tamano(raiz)
|
|
63
|
+
afirma("· measuring the install does not count the linked repo",
|
|
64
|
+
tam < 100_000, f"{tam} bytes — it walked into the repo")
|
|
65
|
+
|
|
66
|
+
quitado = desinstala._quita_carpeta(raiz)
|
|
67
|
+
afirma("· the install folder is gone", quitado and not os.path.exists(raiz), raiz)
|
|
68
|
+
afirma("· and the linked repository is untouched",
|
|
69
|
+
os.path.isfile(os.path.join(repo, "src", "importante.txt")), repo)
|
|
70
|
+
afirma("· and so is the linked folder of documents",
|
|
71
|
+
os.path.isfile(os.path.join(docs, "uno.md")), docs)
|
|
72
|
+
afirma("· the work folder still holds both",
|
|
73
|
+
sorted(os.listdir(trabajo)) == ["el-repo", "manual"], str(os.listdir(trabajo)))
|
|
74
|
+
|
|
75
|
+
print(" and what it does with what is not there")
|
|
76
|
+
comprueba("· removing a folder that never existed is not an error",
|
|
77
|
+
desinstala._quita_carpeta(os.path.join(casa, "no-existe")), False)
|
|
78
|
+
suelto = os.path.join(casa, "suelto")
|
|
79
|
+
os.symlink(repo, suelto)
|
|
80
|
+
afirma("· a link that IS the target is unlinked, not descended into",
|
|
81
|
+
desinstala._quita_carpeta(suelto) and not os.path.lexists(suelto)
|
|
82
|
+
and os.path.isdir(repo), repo)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def el_plan(casa):
|
|
86
|
+
print(" the plan")
|
|
87
|
+
pasos = desinstala.plan()
|
|
88
|
+
clases = [p["clase"] for p in pasos]
|
|
89
|
+
afirma("· it stops what is running before it deletes anything",
|
|
90
|
+
clases[0] == "procesos", str(clases))
|
|
91
|
+
afirma("· it asks Claude to forget the plugin", "plugin" in clases, str(clases))
|
|
92
|
+
carpetas = [p["ruta"] for p in pasos if p["clase"] == "carpeta"]
|
|
93
|
+
afirma("· it removes the cities root", any("agents-city" in r for r in carpetas), str(carpetas))
|
|
94
|
+
afirma("· it removes the cached disk index",
|
|
95
|
+
any(r.endswith(os.path.join("cache", "agents-city")) for r in carpetas), str(carpetas))
|
|
96
|
+
afirma("· it removes the bus folder", any("city-bus" in r for r in carpetas), str(carpetas))
|
|
97
|
+
afirma("· every folder in the plan actually exists",
|
|
98
|
+
all(os.path.exists(r) for r in carpetas), str(carpetas))
|
|
99
|
+
afirma("· nothing outside this product is in the plan",
|
|
100
|
+
all(casa in r or ".claude" in r or ".config" in r or ".cache" in r for r in carpetas),
|
|
101
|
+
str(carpetas))
|
|
102
|
+
|
|
103
|
+
print(" keeping your cities")
|
|
104
|
+
conservado = [p["ruta"] for p in desinstala.plan(conservar_ciudades=True)
|
|
105
|
+
if p["clase"] == "carpeta"]
|
|
106
|
+
afirma("· --keep-cities leaves the cities root alone",
|
|
107
|
+
not any(r == cities.raiz() for r in conservado), str(conservado))
|
|
108
|
+
afirma("· and still unwires the machine", len(conservado) >= 1, str(conservado))
|
|
109
|
+
|
|
110
|
+
print(" the npm package")
|
|
111
|
+
afirma("· it is not removed unless asked",
|
|
112
|
+
"npm" not in [p["clase"] for p in desinstala.plan()], "")
|
|
113
|
+
afirma("· and it is the very last thing when it is",
|
|
114
|
+
desinstala.plan(con_npm=True)[-1]["clase"] == "npm", "")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def la_puerta():
|
|
118
|
+
print(" the front door")
|
|
119
|
+
import io
|
|
120
|
+
from contextlib import redirect_stdout
|
|
121
|
+
|
|
122
|
+
salida = io.StringIO()
|
|
123
|
+
with redirect_stdout(salida):
|
|
124
|
+
codigo = desinstala.main([])
|
|
125
|
+
texto = salida.getvalue()
|
|
126
|
+
comprueba("· a bare uninstall exits cleanly", codigo, 0)
|
|
127
|
+
afirma("· and says plainly that it removed nothing",
|
|
128
|
+
"Nothing was removed" in texto, texto)
|
|
129
|
+
afirma("· and promises not to touch your repositories",
|
|
130
|
+
"NOT touch your repositories" in texto, texto)
|
|
131
|
+
afirma("· the cities root is still there afterwards",
|
|
132
|
+
os.path.isdir(cities.raiz()), cities.raiz())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main():
|
|
136
|
+
casa, trabajo, repo, docs, ciudad = monta()
|
|
137
|
+
viejo = dict(os.environ)
|
|
138
|
+
registro = cities.REGISTRO
|
|
139
|
+
try:
|
|
140
|
+
os.environ["AGENTS_CITY_HOME"] = os.path.join(casa, "agents-city")
|
|
141
|
+
os.environ["AGENTS_CITY_USER"] = "yo"
|
|
142
|
+
os.environ["XDG_CACHE_HOME"] = os.path.join(casa, "cache")
|
|
143
|
+
os.environ["CITY_DIR"] = os.path.join(casa, ".claude", "channels", "city-bus")
|
|
144
|
+
os.environ["CITY_DESKTOP"] = tempfile.mkdtemp()
|
|
145
|
+
cities.REGISTRO = os.path.join(casa, ".config", "agents-city", "cities")
|
|
146
|
+
for ruta in (os.environ["XDG_CACHE_HOME"] + "/agents-city",
|
|
147
|
+
os.environ["CITY_DIR"],
|
|
148
|
+
os.path.dirname(cities.REGISTRO)):
|
|
149
|
+
os.makedirs(ruta, exist_ok=True)
|
|
150
|
+
with open(os.path.join(ruta, "algo"), "w") as f:
|
|
151
|
+
f.write("x")
|
|
152
|
+
# city_env read CITY_DIR at import time, like the rest of the product.
|
|
153
|
+
import city_env
|
|
154
|
+
|
|
155
|
+
city_env.CANAL = os.environ["CITY_DIR"]
|
|
156
|
+
|
|
157
|
+
el_plan(casa)
|
|
158
|
+
la_puerta()
|
|
159
|
+
enlaces(casa, trabajo, repo, docs, ciudad)
|
|
160
|
+
finally:
|
|
161
|
+
cities.REGISTRO = registro
|
|
162
|
+
os.environ.clear()
|
|
163
|
+
os.environ.update(viejo)
|
|
164
|
+
shutil.rmtree(casa, ignore_errors=True)
|
|
165
|
+
shutil.rmtree(trabajo, ignore_errors=True)
|
|
166
|
+
return resumen("desinstala")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
sys.exit(main())
|
package/bin/test-navegador.py
CHANGED
|
@@ -143,6 +143,8 @@ def main():
|
|
|
143
143
|
if limpia.startswith("ok ·") or limpia.startswith("FAIL·"):
|
|
144
144
|
vistas += 1
|
|
145
145
|
afirma("· " + limpia.split("·", 1)[1].strip(), limpia.startswith("ok"), "")
|
|
146
|
+
elif limpia and vistas and not limpia.startswith("ok"):
|
|
147
|
+
print(" " + limpia[:400])
|
|
146
148
|
if not vistas:
|
|
147
149
|
afirma("· the browser checks ran at all", False, texto.strip()[-500:])
|
|
148
150
|
elif salida.returncode != 0:
|
package/bin/test-serve.py
CHANGED
|
@@ -60,8 +60,7 @@ def main():
|
|
|
60
60
|
hilo.start()
|
|
61
61
|
|
|
62
62
|
# The hall reads whatever city AGENTS_CITY_DATA points at; give it one of its
|
|
63
|
-
# own so this suite never touches the caller's
|
|
64
|
-
# /api/misrepos cannot wander off crawling the real home directory.
|
|
63
|
+
# own so this suite never touches the caller's.
|
|
65
64
|
hallDatos = tempfile.mkdtemp()
|
|
66
65
|
appDatos = tempfile.mkdtemp()
|
|
67
66
|
arbitraryDatos = tempfile.mkdtemp()
|
|
@@ -237,6 +236,32 @@ def main():
|
|
|
237
236
|
and "MAP_ACTIVITY_PROTOCOL" in hall_source
|
|
238
237
|
and "enviaActividadAlMapa(event)" in hall_source,
|
|
239
238
|
)
|
|
239
|
+
# The map is framed by the Hall, and it shipped with no day palette at
|
|
240
|
+
# all: `:root` was the night one and the "light" floor colour was in
|
|
241
|
+
# fact DARKER than the night floor, so asking for daylight dimmed the
|
|
242
|
+
# map. A light Hall around a night map reads as two products.
|
|
243
|
+
mapa_fuente = open(os.path.join(RAIZ, "city", "web", "src", "main.ts")).read()
|
|
244
|
+
claro = int(_re.search(r"const SUELO_CLARO = 0x([0-9a-f]{6})", mapa_fuente).group(1), 16)
|
|
245
|
+
noche = int(_re.search(r"const SUELO = 0x([0-9a-f]{6})", mapa_fuente).group(1), 16)
|
|
246
|
+
afirma(
|
|
247
|
+
"· the map's daylight floor is actually lighter than its night one",
|
|
248
|
+
claro > noche, f"light={claro:#08x} night={noche:#08x}",
|
|
249
|
+
)
|
|
250
|
+
afirma(
|
|
251
|
+
"· and the map's own palette is day first, night only when stamped",
|
|
252
|
+
':root[data-tema="dark"]{' in map_html.replace("\n", "")
|
|
253
|
+
and "@media(prefers-color-scheme" not in map_html.replace(" ", "")
|
|
254
|
+
and map_html.index(":root{") < map_html.index(':root[data-tema="dark"]'),
|
|
255
|
+
map_html[:900],
|
|
256
|
+
)
|
|
257
|
+
plano = " ".join(mapa_fuente.split())
|
|
258
|
+
afirma(
|
|
259
|
+
"· the map boots on the skin its own stylesheet defaults to",
|
|
260
|
+
"esDeDia ? SUELO_CLARO : SUELO" in plano
|
|
261
|
+
and "background: esDeDia ? SUELO_CLARO : SUELO" in plano,
|
|
262
|
+
"",
|
|
263
|
+
)
|
|
264
|
+
|
|
240
265
|
afirma(
|
|
241
266
|
"· game speech is anchored to a real actor and starts with its recipient",
|
|
242
267
|
"speech.target.getBounds()" in speech_source
|
|
@@ -739,6 +764,101 @@ def main():
|
|
|
739
764
|
)
|
|
740
765
|
pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": ""})
|
|
741
766
|
|
|
767
|
+
# ── the demo shelf ───────────────────────────────────────────────────
|
|
768
|
+
print(" the demos the Hall plays back")
|
|
769
|
+
st, cuerpo = pide(puerto, "/api/demos")
|
|
770
|
+
lista = json.loads(cuerpo)["demos"]
|
|
771
|
+
comprueba("· the shelf lists", st, 200)
|
|
772
|
+
afirma("· one demo per work domain, each named",
|
|
773
|
+
len(lista) == 3 and all(d["titulo"] and d["ciudad"] and d["dominio"] for d in lista),
|
|
774
|
+
str(lista)[:300])
|
|
775
|
+
afirma("· with its cast and its length, so a card can be read before playing",
|
|
776
|
+
all(d["turnos"] > 5 and len(d["reparto"]) >= 3 for d in lista), str(lista)[:300])
|
|
777
|
+
afirma("· and the shelf alone carries no turns — a card is not a download",
|
|
778
|
+
all("eventos" not in d for d in lista), str(lista)[:200])
|
|
779
|
+
|
|
780
|
+
st, cuerpo = pide(puerto, "/api/demos?story=software")
|
|
781
|
+
grabacion = json.loads(cuerpo)
|
|
782
|
+
comprueba("· one demo comes back in full", st, 200)
|
|
783
|
+
afirma("· as the very events the real bus produced",
|
|
784
|
+
all(e.get("protocol") == "agents-city-activity/1" for e in grabacion["eventos"])
|
|
785
|
+
and grabacion["eventos"][0]["kind"] == "committee.opened",
|
|
786
|
+
str(grabacion["eventos"][0])[:200])
|
|
787
|
+
afirma("· carrying no dead city's identifiers into this one",
|
|
788
|
+
not any(k in grabacion["eventos"][0] for k in ("id", "at", "city", "thread")),
|
|
789
|
+
str(grabacion["eventos"][0])[:200])
|
|
790
|
+
st, _ = pide(puerto, "/api/demos?story=../../etc/passwd")
|
|
791
|
+
comprueba("· and a demo nobody recorded is a 404, never a file read", st, 404)
|
|
792
|
+
|
|
793
|
+
# ── walking the disk ─────────────────────────────────────────────────
|
|
794
|
+
# The picker before this one scanned the whole machine and offered what
|
|
795
|
+
# it guessed you wanted, which could only ever offer what it knew how to
|
|
796
|
+
# look for. This one lists one folder and gets out of the way.
|
|
797
|
+
print(" the folder picker")
|
|
798
|
+
paseo = tempfile.mkdtemp()
|
|
799
|
+
os.makedirs(os.path.join(paseo, "un-repo", ".git"))
|
|
800
|
+
os.makedirs(os.path.join(paseo, "un-arbol"))
|
|
801
|
+
open(os.path.join(paseo, "un-arbol", ".git"), "w").close()
|
|
802
|
+
os.makedirs(os.path.join(paseo, "notas"))
|
|
803
|
+
open(os.path.join(paseo, "una-nota.md"), "w").close()
|
|
804
|
+
open(os.path.join(paseo, ".escondido"), "w").close()
|
|
805
|
+
|
|
806
|
+
st, cuerpo = pide(puerto, "/api/carpeta?path=" + urllib.parse.quote(paseo))
|
|
807
|
+
datos_carpeta = json.loads(cuerpo)
|
|
808
|
+
nombres = [e["nombre"] for e in datos_carpeta["entradas"]]
|
|
809
|
+
clases = {e["nombre"]: e for e in datos_carpeta["entradas"]}
|
|
810
|
+
comprueba("· a folder lists", st, 200)
|
|
811
|
+
afirma(
|
|
812
|
+
"· folders first, then files, each alphabetical",
|
|
813
|
+
nombres == ["notas", "un-arbol", "un-repo", "una-nota.md"], str(nombres),
|
|
814
|
+
)
|
|
815
|
+
afirma("· a file is offered too — it is a thing somebody works on",
|
|
816
|
+
clases["una-nota.md"]["dir"] is False, str(clases["una-nota.md"]))
|
|
817
|
+
afirma("· a clone is labelled, not filtered out",
|
|
818
|
+
clases["un-repo"]["git"] == "repo", str(clases["un-repo"]))
|
|
819
|
+
afirma("· and so is a worktree",
|
|
820
|
+
clases["un-arbol"]["git"] == "worktree", str(clases["un-arbol"]))
|
|
821
|
+
afirma("· a folder with nothing special is offered exactly the same",
|
|
822
|
+
clases["notas"]["git"] == "" and clases["notas"]["dir"], str(clases["notas"]))
|
|
823
|
+
afirma("· dotfiles stay out of the way unless asked for",
|
|
824
|
+
".escondido" not in nombres, str(nombres))
|
|
825
|
+
st, cuerpo = pide(
|
|
826
|
+
puerto, "/api/carpeta?hidden=1&path=" + urllib.parse.quote(paseo)
|
|
827
|
+
)
|
|
828
|
+
afirma("· and are there when they are asked for",
|
|
829
|
+
".escondido" in [e["nombre"] for e in json.loads(cuerpo)["entradas"]], cuerpo)
|
|
830
|
+
afirma("· it says where you are and what is above you",
|
|
831
|
+
datos_carpeta["ruta"] == os.path.realpath(paseo)
|
|
832
|
+
and datos_carpeta["arriba"] == os.path.dirname(os.path.realpath(paseo)),
|
|
833
|
+
str(datos_carpeta)[:200])
|
|
834
|
+
afirma("· and offers the usual places to start from",
|
|
835
|
+
any(a["ruta"] == os.path.expanduser("~") for a in datos_carpeta["atajos"]),
|
|
836
|
+
str(datos_carpeta["atajos"]))
|
|
837
|
+
st, _ = pide(puerto, "/api/carpeta?path=" + urllib.parse.quote(paseo + "/no-existe"))
|
|
838
|
+
comprueba("· a folder that is not there is a 404, not a crash", st, 404)
|
|
839
|
+
st, cuerpo = pide(
|
|
840
|
+
puerto, "/api/carpeta?path=" + urllib.parse.quote(os.path.join(paseo, "una-nota.md"))
|
|
841
|
+
)
|
|
842
|
+
comprueba("· and neither is a file a folder", st, 404)
|
|
843
|
+
|
|
844
|
+
# A mount may be one exact file: a handbook is a folder, a spec is a file.
|
|
845
|
+
st, cuerpo = pide(
|
|
846
|
+
puerto,
|
|
847
|
+
"/api/montaje",
|
|
848
|
+
metodo="POST",
|
|
849
|
+
cuerpo={"agent": "notas", "add": os.path.join(paseo, "una-nota.md")},
|
|
850
|
+
)
|
|
851
|
+
afirma("· one exact file can be mounted, not only a folder",
|
|
852
|
+
st == 200 and json.loads(cuerpo).get("ok"), f"{st} {cuerpo}")
|
|
853
|
+
st, cuerpo = pide(
|
|
854
|
+
puerto,
|
|
855
|
+
"/api/montaje",
|
|
856
|
+
metodo="POST",
|
|
857
|
+
cuerpo={"agent": "notas", "add": os.path.join(paseo, "no-hay-nada")},
|
|
858
|
+
)
|
|
859
|
+
comprueba("· and something that is not there is still refused", st, 400)
|
|
860
|
+
shutil.rmtree(paseo, ignore_errors=True)
|
|
861
|
+
|
|
742
862
|
# ── the roster, from the web ─────────────────────────────────────────
|
|
743
863
|
print(" building the roster from the Hall")
|
|
744
864
|
st, cuerpo = pide(
|