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/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 find-repos scan.
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 _dia(cuando):
526
- """A unix time as a plain date, or nothing. The picker shows when a place
527
- was last touched, and an epoch integer is not something anybody reads."""
528
- if not cuando:
529
- return ""
530
- return time.strftime("%Y-%m-%d", time.localtime(cuando))
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/misrepos": "g_misrepos",
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 g_misrepos(self, q):
856
- """Everything on this disk an agent could be given to work on.
858
+ def g_demos(self, q):
859
+ """The recorded demos, and one of them in full when asked by name.
857
860
 
858
- Repositories, linked worktrees and folders of documents, most recently
859
- touched first — the same index the seat and the launcher read, so the
860
- picker and the terminal can never disagree about what is out there.
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
- The version before this one ran `git log` in every repository to mark
863
- the ones you had committed to. On a machine with three hundred clones
864
- that is three hundred processes while somebody watches a spinner, and
865
- the answer it bought — "yours" — is one a single-user product can read
866
- off the filesystem for free: what you touched this month is yours.
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
- # ?refresh=1 rebuilds the index first. The cache lasts a day, and the
869
- # repo somebody just cloned is exactly the one they came to tick.
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
- sitios = busca.lugares(refrescar=bool(q.get("refresh", [""])[0]))
872
- except OSError:
873
- sitios = []
874
- frontera = time.time() - 60 * 86400
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
- "repos": [
878
- {
879
- "nombre": s["nombre"],
880
- "ruta": s["ruta"],
881
- "clase": s.get("clase", "repo"),
882
- "cuando": _dia(s.get("cuando", 0.0)),
883
- "mio": s.get("cuando", 0.0) >= frontera,
884
- }
885
- for s in sitios
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
- if not os.path.isdir(destino):
1330
- return self.responde({"error": f"there is no folder at {destino}"}, codigo=400)
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/capabilities.py",
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())
@@ -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, and an empty disk index so
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(
package/bin/uninstall ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Remove agents-city and everything it wrote on this machine.
3
+ # Preview by default; --yes goes through with it.
4
+ set -euo pipefail
5
+ exec python3 "$(dirname "$0")/../plugin/scripts/desinstala.py" "$@"