agents-city 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +11 -0
  3. package/README.md +10 -0
  4. package/benchmarks/latency/fake-claude-cli.mjs +3 -2
  5. package/bin/serve.py +63 -6
  6. package/bin/test +2 -2
  7. package/bin/test-alcance.py +470 -0
  8. package/bin/test-arnes.py +109 -0
  9. package/bin/test-channel.py +295 -9
  10. package/bin/test-cities.py +57 -0
  11. package/bin/test-claude-runtime.py +17 -6
  12. package/bin/test-connect-client.mjs +8 -0
  13. package/bin/test-connect.py +1 -1
  14. package/bin/test-contracts.py +129 -3
  15. package/bin/test-e2e.py +371 -0
  16. package/bin/test-seat.py +154 -1
  17. package/bin/test-serve.py +83 -0
  18. package/city/web/dist-hall/hall.js +384 -3240
  19. package/docs/managed-connect.md +5 -0
  20. package/package.json +1 -1
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/channel/hub/lo-que-te-llega.ts +78 -0
  23. package/plugin/channel/hub/local-roads.ts +36 -0
  24. package/plugin/channel/hub/road-controller.ts +30 -5
  25. package/plugin/channel/local-hub.js +177 -105
  26. package/plugin/channel/local-hub.ts +8 -0
  27. package/plugin/channel/managed-connect-client.js +53 -3
  28. package/plugin/channel/managed-connect-client.manifest.json +2 -2
  29. package/plugin/channel/protocol.ts +24 -0
  30. package/plugin/commands/notice.md +14 -0
  31. package/plugin/hooks/ask-the-house.sh +25 -0
  32. package/plugin/hooks/hooks.json +10 -0
  33. package/plugin/hooks/notice-on-stop.sh +7 -1
  34. package/plugin/scripts/alcance.py +318 -0
  35. package/plugin/scripts/arnes.py +10 -1
  36. package/plugin/scripts/busca.py +13 -0
  37. package/plugin/scripts/card.py +40 -0
  38. package/plugin/scripts/cities.py +46 -4
  39. package/plugin/scripts/city-session.sh +80 -7
  40. package/plugin/scripts/domains.py +7 -0
  41. package/plugin/scripts/seat.py +33 -13
  42. package/plugin/skills/city/SKILL.md +12 -0
@@ -269,6 +269,46 @@ def conciencia_acotada():
269
269
 
270
270
 
271
271
  # ══ the demo is a city this code would write ═════════════════════════════════
272
+ # ══ the shell has to run on somebody else's bash ═════════════════════════════
273
+ def bash_que_no_es_la_de_macos():
274
+ """Shell that only bash 3.2 accepts is shell that only works on macOS.
275
+
276
+ `${#array[@]-0}` is the trap that got through: bash 3.2 — the one Apple has
277
+ shipped since 2007, and the reason this repo writes `${a[@]+"${a[@]}"}`
278
+ everywhere — accepts it, and every bash from 4.4 on refuses it as a bad
279
+ substitution. So a message printed here and never on Linux, silently, because
280
+ the error goes to stderr and the script does not stop. It sat in the launcher
281
+ for months: nothing read stderr until a test did.
282
+
283
+ A declared array needs no default. `a=()` already answers 0 under `set -u`;
284
+ only an UNDECLARED name is unbound, and the fix for that is to declare it.
285
+
286
+ shellcheck does not flag this at any severity, which is why it is here.
287
+ """
288
+ print(' shell that runs on a bash newer than 2007')
289
+ trampa = re.compile(r'\$\{#[A-Za-z_][A-Za-z0-9_]*\[[@*]\][-:+]')
290
+ ficheros = sorted(glob.glob(os.path.join(RAIZ, 'plugin', '**', '*.sh'), recursive=True))
291
+ ficheros += sorted(glob.glob(os.path.join(RAIZ, 'bus', 'scripts', '*.sh')))
292
+ culpables = []
293
+ for ruta in ficheros:
294
+ with open(ruta, encoding='utf-8') as f:
295
+ for n, linea in enumerate(f, 1):
296
+ # A comment may name the trap — the tombstone in the launcher does.
297
+ if linea.lstrip().startswith('#'):
298
+ continue
299
+ if trampa.search(linea):
300
+ culpables.append(f'{os.path.relpath(ruta, RAIZ)}:{n}')
301
+ afirma('· no shipped script uses a length expansion with a default',
302
+ not culpables,
303
+ '${#a[@]-0} is a macOS-only illusion: ' + ', '.join(culpables))
304
+ # And the idiom that IS portable stays in use, so this does not read as a
305
+ # rule against defaults in general.
306
+ lanzador = open(os.path.join(RAIZ, 'plugin/scripts/city-session.sh'),
307
+ encoding='utf-8').read()
308
+ afirma('· and empty arrays are still expanded the way bash 3.2 needs',
309
+ '[@]+"${' in lanzador, '')
310
+
311
+
272
312
  def demo_coherente():
273
313
  print(' the demo agrees with the code')
274
314
  for f in sorted(glob.glob(os.path.join(RAIZ, 'demo', '*.md'))):
@@ -462,6 +502,49 @@ def asiento_con_su_arnes():
462
502
  shutil.rmtree(casa, ignore_errors=True)
463
503
 
464
504
 
505
+ def el_aviso_lee_lo_que_viaja():
506
+ """What the roster carries has to be what the judgement reads.
507
+
508
+ The circuit is three pieces and it is only worth the sum if all three are
509
+ wired: a city publishes its role and its remit, the roster serves them, and
510
+ the thing that decides "does this reach anybody" reads THAT rather than a
511
+ local copy of a catalogue. Build the first two and leave the third pointing
512
+ at local files and you have a pipe nobody reads — which is what this was
513
+ before, and it looked finished.
514
+ """
515
+ print(' the judgement reads what the roster carries')
516
+ # Whitespace-collapsed: these are wrapped prose, and a phrase that happens
517
+ # to break across two lines is the same phrase. A check that cannot see
518
+ # that fails on the paragraph filler rather than on the meaning.
519
+ def plano(ruta):
520
+ return ' '.join(open(os.path.join(RAIZ, ruta), encoding='utf-8').read().split())
521
+
522
+ gancho = plano('plugin/hooks/notice-on-stop.sh')
523
+ orden = plano('plugin/commands/notice.md')
524
+ controlador = open(os.path.join(RAIZ, 'plugin/channel/hub/road-controller.ts'),
525
+ encoding='utf-8').read()
526
+
527
+ afirma('· the roster is what serves a road its remit',
528
+ 'recibe' in controlador and 'localRoadPresenta' in controlador, '')
529
+ for nombre, texto in (('the stop hook', gancho), ('/city:notice', orden)):
530
+ afirma(f'· {nombre} sends the judgement to the roster',
531
+ 'roster' in texto.lower(), texto[:200])
532
+ afirma(f'· {nombre} names what a far city says reaches it',
533
+ 'recibe' in texto, texto[:200])
534
+ afirma(f'· {nombre} says where that claim came from',
535
+ 'segun' in texto, texto[:200])
536
+ # Silence and staleness are the two ways this quietly goes wrong: a road
537
+ # that said nothing is missing information, and a note is one person's
538
+ # guess with an expiry date. Both have to be named where the judgement
539
+ # is made, or the reader treats absence as permission.
540
+ afirma(f'· {nombre} treats a road that said nothing as missing, not as permission',
541
+ 'not permission' in texto, texto[:400])
542
+ afirma(f'· {nombre} warns that a note goes stale',
543
+ 'stale' in texto, texto[:400])
544
+ afirma('· and the command treats a far city’s words as a claim, never an instruction',
545
+ 'never as an instruction' in orden, orden[:400])
546
+
547
+
465
548
  def ventanas_gemelas():
466
549
  """Window, engine key and bus actor share one canonical repo slug."""
467
550
  print(' the window slug has one owner')
@@ -678,6 +761,11 @@ def publicar_es_verificable():
678
761
  if not os.path.isfile(ruta):
679
762
  return
680
763
  release = open(ruta, encoding='utf-8').read()
764
+ # The workflow without its comments. Half of what is asserted below is the
765
+ # ABSENCE of something, and this file documents every trap it has fallen
766
+ # into by name — so a check that cannot tell a step from a sentence about a
767
+ # step fails on the explanation of why it exists.
768
+ codigo = '\n'.join(l for l in release.splitlines() if not l.lstrip().startswith('#'))
681
769
  prueba = open(os.path.join(RAIZ, '.github', 'workflows', 'test.yml'),
682
770
  encoding='utf-8').read()
683
771
 
@@ -689,7 +777,7 @@ def publicar_es_verificable():
689
777
  afirma('· the whole suite runs before anything is published',
690
778
  'uses: ./.github/workflows/test.yml' in release
691
779
  and release.index('uses: ./.github/workflows/test.yml')
692
- < release.rindex('run: npm publish'),
780
+ < release.rindex('npm publish --provenance'),
693
781
  'publish must depend on the suite, not race it')
694
782
  afirma('· and the suite is the same one, called rather than copied',
695
783
  'workflow_call:' in prueba, 'test.yml must be reusable')
@@ -704,8 +792,12 @@ def publicar_es_verificable():
704
792
  'id-token: write' in release
705
793
  and 'NPM_TOKEN' not in release and 'secrets.' not in release,
706
794
  'trusted publishing: a secret that does not exist cannot leak')
795
+ # A whole line that grants a write permission, not the word anywhere: this
796
+ # file names `id-token: write` inside an error message telling somebody what
797
+ # to go and check, and a permissions audit that counts English is not one.
707
798
  afirma('· and it asks for nothing else',
708
- release.count('write') == 1, 'id-token is the only write permission')
799
+ len(re.findall(r'(?m)^\s*[a-z-]+:\s*write\s*$', codigo)) == 1,
800
+ 'id-token is the only write permission')
709
801
  # Node 22 bundles npm 10, which cannot exchange an OIDC token and so
710
802
  # publishes UNAUTHENTICATED — the registry answers 404, which reads like a
711
803
  # missing package and is really a missing credential. It cost one release
@@ -715,6 +807,38 @@ def publicar_es_verificable():
715
807
  'trusted publishing needs npm >= 11.5.1; setup-node gives 10.x')
716
808
  afirma('· and it refuses to publish with an older one rather than trying',
717
809
  'process.exit(1)' in release, release)
810
+ # And an npm new enough is still not enough if something already handed it a
811
+ # credential, because a credential is how the OIDC exchange gets SKIPPED.
812
+ # `setup-node`'s `registry-url` looks like the line that points npm at
813
+ # npmjs.com; what it does is write `_authToken=${NODE_AUTH_TOKEN}` into an
814
+ # .npmrc and export NODE_AUTH_TOKEN as the literal `XXXXX-XXXXX-XXXXX-XXXXX`.
815
+ # npm then publishes authenticated as a string of X's and the registry
816
+ # answers the same 404 it answers a stranger. It cost the second release.
817
+ afirma('· and nothing hands npm a credential of its own before it asks',
818
+ 'registry-url' not in codigo and 'NODE_AUTH_TOKEN' not in codigo,
819
+ 'setup-node registry-url writes a placeholder token that skips the OIDC exchange')
820
+ afirma('· which is checked in the run rather than inferred from a 404',
821
+ '_authToken' in codigo
822
+ and codigo.index('_authToken') < codigo.rindex('npm publish --provenance'),
823
+ 'the registry says "not found" when it means "not allowed"')
824
+ # Having no credential is only half of it: npm has to be able to GET one,
825
+ # and it only tries when GitHub has injected an OIDC endpoint into the job.
826
+ # Without one npm does not try, does not say it is not trying, and fails at
827
+ # the end with "you need to log in" — which sounds like a forgotten password
828
+ # and is a job that was never given an identity to trade.
829
+ afirma('· and it names the registry it means to be recognised at',
830
+ 'npm config set registry https://registry.npmjs.org/' in codigo
831
+ and codigo.index('npm config set registry')
832
+ < codigo.rindex('npm publish --provenance'),
833
+ 'npm offers an OIDC token only for a registry it recognises as decided')
834
+ afirma('· and a refusal has to explain itself in the log',
835
+ '--loglevel verbose' in codigo,
836
+ 'ENEEDAUTH twice with no reason is a workflow that cannot be debugged remotely')
837
+ publica = codigo.rindex('npm publish --provenance')
838
+ afirma('· and the identity it means to trade is proved to exist first',
839
+ 'ACTIONS_ID_TOKEN_REQUEST_URL' in codigo
840
+ and codigo.index('ACTIONS_ID_TOKEN_REQUEST_URL') < publica,
841
+ 'a missing OIDC endpoint must fail by name, not as ENEEDAUTH ten steps later')
718
842
  # prepublishOnly rebuilds and re-runs the whole suite. By hand that is the
719
843
  # only safety net; here the suite has already run on three platforms and
720
844
  # byte-compared the artifacts, so a fourth run verifies nothing and can
@@ -728,7 +852,7 @@ def publicar_es_verificable():
728
852
  # map and no Hall bundle: 447 files instead of 503.
729
853
  afirma('· but it still builds the gitignored half of the package',
730
854
  'run: npm run build' in release
731
- and release.index('run: npm run build') < release.rindex('run: npm publish'),
855
+ and release.index('run: npm run build') < release.rindex('npm publish --provenance'),
732
856
  'a publish with no build ships no front end')
733
857
  afirma('· and checks the front end is in the tarball before sending it',
734
858
  'city/web/dist/index.html' in release and 'city/web/dist-hall/hall.js' in release,
@@ -747,8 +871,10 @@ def main():
747
871
  una_definicion()
748
872
  rutas_reales()
749
873
  conciencia_acotada()
874
+ bash_que_no_es_la_de_macos()
750
875
  demo_coherente()
751
876
  varias_ciudades()
877
+ el_aviso_lee_lo_que_viaja()
752
878
  ventanas_gemelas()
753
879
  motor_para_todos()
754
880
  asiento_con_su_arnes()
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env python3
2
+ """One city, from nothing to a refusal, through the doors a person actually uses.
3
+
4
+ Every other suite here proves one piece: the Hall's endpoint writes the card,
5
+ the launcher builds the right command, the guard judges a path, the journal
6
+ records a line. Each of them stubs its neighbours, and that is what makes them
7
+ fast and precise — and it is also how a product ends up with every part working
8
+ and the whole thing broken at the seams. The two bugs this release fixes were
9
+ both seam bugs. Adding a house wrote the frontmatter and not the body: two
10
+ correct writers, one incoherent card. Adding a house to a RUNNING city wrote the
11
+ card and never touched tmux: a correct endpoint and a correct launcher that had
12
+ never met.
13
+
14
+ So this one stubs nothing it can avoid. A real HTTP server over a real socket. A
15
+ real `agents-city cities create`. The real `city-session.sh` against a real tmux
16
+ server on a private socket. The real hook binary, over stdin, with the
17
+ environment THE LAUNCHER ITSELF wrote — not one composed here, because "the
18
+ launcher tells the window where its city is" is exactly the kind of fact a
19
+ hand-made fixture will keep asserting long after it stops being true. And the
20
+ journal read back through the API, so the write and the read resolve the city
21
+ the same way.
22
+
23
+ The story it walks, once, in order:
24
+
25
+ create a city → write its seat → add a house → mount its ground → open the
26
+ session → add a SECOND house while the city is running → open it again and
27
+ watch it reconcile → have the chair reach into somebody's repo → be refused,
28
+ by name → find the refusal in the journal → let the owner open the chair's
29
+ hands → watch the same call go through.
30
+ """
31
+
32
+ import json
33
+ import os
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import tempfile
38
+ import threading
39
+ import urllib.error
40
+ import urllib.request
41
+
42
+ AQUI = os.path.dirname(os.path.abspath(__file__))
43
+ RAIZ = os.path.dirname(AQUI)
44
+ sys.path.insert(0, AQUI)
45
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
46
+
47
+ import serve # noqa: E402
48
+ from testlib import afirma, comprueba, detiene_hubs_de_ciudad, resumen # noqa: E402
49
+
50
+ SESION = os.path.join(RAIZ, "plugin", "scripts", "city-session.sh")
51
+ GANCHO = os.path.join(RAIZ, "plugin", "hooks", "ask-the-house.sh")
52
+
53
+
54
+ # ── the doors, each one real ─────────────────────────────────────────────────
55
+
56
+
57
+ def pide(puerto, ruta, metodo="GET", cuerpo=None):
58
+ url = f"http://127.0.0.1:{puerto}{ruta}"
59
+ url += ("&" if "?" in ruta else "?") + "PASE=" + serve.PASE
60
+ req = urllib.request.Request(
61
+ url, method=metodo, data=json.dumps(cuerpo).encode() if cuerpo is not None else None
62
+ )
63
+ try:
64
+ with urllib.request.urlopen(req, timeout=30) as r:
65
+ return r.status, json.loads(r.read() or b"{}")
66
+ except urllib.error.HTTPError as e:
67
+ try:
68
+ return e.code, json.loads(e.read() or b"{}")
69
+ except ValueError:
70
+ return e.code, {}
71
+
72
+
73
+ class Mundo:
74
+ """One machine's worth of isolation: its own app home, tmux server and PATH."""
75
+
76
+ def __init__(self):
77
+ self.base = os.path.realpath(tempfile.mkdtemp(prefix="agents-city-e2e-"))
78
+ self.hogar = os.path.join(self.base, "app")
79
+ self.tmux = os.path.join(self.base, "tmux")
80
+ self.repo = os.path.join(self.base, "codigo", "api")
81
+ self.papeles = os.path.join(self.base, "papeles", "manual")
82
+ for d in (self.hogar, self.tmux, os.path.join(self.repo, "app"), self.papeles):
83
+ os.makedirs(d, exist_ok=True)
84
+ open(os.path.join(self.repo, "app", "router.rb"), "w").write("# routes\n")
85
+ open(os.path.join(self.papeles, "guia.md"), "w").write("# guide\n")
86
+ self.datos = ""
87
+
88
+ @property
89
+ def entorno(self):
90
+ e = dict(os.environ)
91
+ e.update({
92
+ "HOME": self.base,
93
+ "AGENTS_CITY_HOME": self.hogar,
94
+ "AGENTS_CITY_USER": "e2e",
95
+ "TMUX_TMPDIR": self.tmux,
96
+ "CITY_SEARCH_IN": self.base,
97
+ "XDG_CACHE_HOME": os.path.join(self.base, "cache"),
98
+ })
99
+ if self.datos:
100
+ e["AGENTS_CITY_DATA"] = self.datos
101
+ return e
102
+
103
+ def corre(self, *orden, **extra):
104
+ env = self.entorno
105
+ env.update(extra)
106
+ return subprocess.run(
107
+ list(orden), capture_output=True, text=True, env=env, cwd=self.base, timeout=180
108
+ )
109
+
110
+ def ventanas(self, sesion):
111
+ r = self.corre("tmux", "list-windows", "-t", sesion, "-F", "#W")
112
+ return [x for x in r.stdout.split() if x]
113
+
114
+ def paneles(self, sesion):
115
+ r = self.corre("tmux", "list-panes", "-a", "-t", sesion, "-F", "#W\t#{pane_id}")
116
+ fuera = {}
117
+ for linea in r.stdout.splitlines():
118
+ if "\t" in linea:
119
+ ventana, panel = linea.split("\t", 1)
120
+ fuera.setdefault(ventana, panel)
121
+ return fuera
122
+
123
+ def entorno_de_la_sesion(self, sesion):
124
+ """What the launcher told the session. Read back from tmux, not composed."""
125
+ r = self.corre("tmux", "show-environment", "-t", sesion)
126
+ fuera = {}
127
+ for linea in r.stdout.splitlines():
128
+ if "=" in linea and not linea.startswith("-"):
129
+ k, v = linea.split("=", 1)
130
+ fuera[k] = v
131
+ return fuera
132
+
133
+ def limpia(self):
134
+ self.corre("tmux", "kill-server")
135
+ if self.datos:
136
+ detiene_hubs_de_ciudad(self.datos)
137
+ shutil.rmtree(self.base, ignore_errors=True)
138
+
139
+
140
+ def abre_la_sesion(mundo, usuario="e2e"):
141
+ """The real launcher. It ends in `tmux attach`, which cannot succeed with no
142
+ terminal — the windows are built by then, which is what the Hall relies on
143
+ when it opens a city from the browser."""
144
+ return mundo.corre("bash", SESION, usuario)
145
+
146
+
147
+ def llama_al_gancho(mundo, sesion, herramienta, entrada, **extra):
148
+ """One tool call through the real hook, in the environment the launcher wrote.
149
+
150
+ The seat's own identity (`CITY_BUS_ACTOR=seat`) is the one value added here:
151
+ the launcher writes it in front of the runtime's command line rather than
152
+ into the session, so tmux cannot hand it back. Everything else — which city,
153
+ which home, which owner — comes from what the launcher actually set.
154
+ """
155
+ env = dict(mundo.entorno)
156
+ env.pop("AGENTS_CITY_DATA", None)
157
+ env.update(mundo.entorno_de_la_sesion(sesion))
158
+ env["CITY_BUS_ACTOR"] = "seat"
159
+ env["CLAUDE_PLUGIN_ROOT"] = os.path.join(RAIZ, "plugin")
160
+ env.update(extra)
161
+ r = subprocess.run(
162
+ ["/bin/bash", GANCHO],
163
+ input=json.dumps({"tool_name": herramienta, "tool_input": entrada, "cwd": mundo.datos}),
164
+ capture_output=True, text=True, env=env, timeout=60,
165
+ )
166
+ try:
167
+ return json.loads(r.stdout or "{}"), r
168
+ except ValueError:
169
+ return {}, r
170
+
171
+
172
+ def decision(salida):
173
+ return (salida.get("hookSpecificOutput") or {}).get("permissionDecision", "")
174
+
175
+
176
+ def motivo(salida):
177
+ return (salida.get("hookSpecificOutput") or {}).get("permissionDecisionReason", "")
178
+
179
+
180
+ # ── the story ────────────────────────────────────────────────────────────────
181
+
182
+
183
+ def main():
184
+ print("\n one city, end to end")
185
+ if not shutil.which("tmux"):
186
+ afirma("· tmux is available", False, "tmux missing")
187
+ return resumen("e2e")
188
+
189
+ mundo = Mundo()
190
+ servidor = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
191
+ puerto = servidor.server_port
192
+ threading.Thread(target=servidor.serve_forever, daemon=True).start()
193
+ previo = {k: os.environ.get(k) for k in
194
+ ("AGENTS_CITY_DATA", "AGENTS_CITY_HOME", "AGENTS_CITY_USER",
195
+ "CITY_SEARCH_IN", "XDG_CACHE_HOME", "HOME")}
196
+ registro_previo = serve.cities.REGISTRO
197
+ serve.cities.REGISTRO = os.path.join(mundo.base, "registro", "cities")
198
+
199
+ try:
200
+ # ── 1. a city, through its own door ──────────────────────────────────
201
+ print(" a city, a seat, a house")
202
+ r = mundo.corre(sys.executable, os.path.join(RAIZ, "plugin", "scripts", "cities.py"),
203
+ "create", "home")
204
+ mundo.datos = os.path.join(mundo.hogar, "e2e", "home")
205
+ afirma(
206
+ "· `cities create` builds a city where a city goes",
207
+ r.returncode == 0 and os.path.isfile(os.path.join(mundo.datos, "city.yml")),
208
+ f"{r.stdout}{r.stderr}",
209
+ )
210
+ # The Hall serves whatever AGENTS_CITY_DATA points at; this process is
211
+ # the server, so it has to be told too.
212
+ os.environ.update({k: v for k, v in mundo.entorno.items() if k in previo})
213
+
214
+ # ── 2. the seat, through the Hall ────────────────────────────────────
215
+ st, _ = pide(puerto, "/api/ficha", metodo="POST",
216
+ cuerpo={"role": "cpto", "domain": "software"})
217
+ ficha = os.path.join(mundo.datos, "e2e.md")
218
+ afirma("· the browser writes the owner card", st == 200 and os.path.isfile(ficha),
219
+ f"{st}")
220
+
221
+ # ── 3. a house, and the ground it works ─────────────────────────────
222
+ st, _ = pide(puerto, "/api/agentes", metodo="POST",
223
+ cuerpo={"name": "api", "kind": "code", "role": "dev"})
224
+ comprueba("· and adds a house to it", st, 200)
225
+ # The frontmatter is what the launcher and the cage read; the body is
226
+ # what the SEAT reads. Both, or the city and its own chair disagree.
227
+ cuerpo_carta = open(ficha, encoding="utf-8").read().split("## Agents", 1)[-1]
228
+ cuerpo_carta = cuerpo_carta.split("## ", 1)[0]
229
+ afirma(
230
+ "· in the half the launcher reads and the half the seat reads",
231
+ "`api`" in cuerpo_carta and "0 mounts" in cuerpo_carta,
232
+ cuerpo_carta[:300],
233
+ )
234
+ st, montado = pide(puerto, "/api/montaje", metodo="POST",
235
+ cuerpo={"agent": "api", "add": mundo.repo})
236
+ carta = open(ficha, encoding="utf-8").read()
237
+ cuerpo_carta = carta.split("## Agents", 1)[-1].split("## ", 1)[0]
238
+ afirma(
239
+ "· and the ground it works lands on disk and on the card, in both too",
240
+ st == 200
241
+ and any(m["target"] == mundo.repo for m in montado.get("mounts", []))
242
+ and "mounts.api:" in carta
243
+ and "1 mount." in cuerpo_carta,
244
+ carta,
245
+ )
246
+
247
+ # ── 4. open the city ────────────────────────────────────────────────
248
+ print(" and then it is opened, and changed while it is open")
249
+ abre_la_sesion(mundo)
250
+ sesion = serve.cities.sesion("e2e", mundo.datos)
251
+ ventanas = mundo.ventanas(sesion)
252
+ afirma("· the session opens with the chair and the house",
253
+ "seat" in ventanas and "api" in ventanas, str(ventanas))
254
+ antes = mundo.paneles(sesion)
255
+
256
+ # ── 5. a second house, while the city is running ────────────────────
257
+ st, _ = pide(puerto, "/api/agentes", metodo="POST",
258
+ cuerpo={"name": "manual", "kind": "knowledge", "role": "seo"})
259
+ pide(puerto, "/api/montaje", metodo="POST",
260
+ cuerpo={"agent": "manual", "add": mundo.papeles})
261
+ afirma("· a house can be added to a city that is already open", st == 200)
262
+ afirma(
263
+ "· non-happy: and until the city is opened again, it is not there",
264
+ "manual" not in mundo.ventanas(sesion),
265
+ str(mundo.ventanas(sesion)),
266
+ )
267
+
268
+ # ── 6. open it again: reconcile, do not rebuild ─────────────────────
269
+ salida = abre_la_sesion(mundo)
270
+ ventanas = mundo.ventanas(sesion)
271
+ despues = mundo.paneles(sesion)
272
+ afirma("· opening it again brings the new house in", "manual" in ventanas, str(ventanas))
273
+ afirma(
274
+ "· non-happy: without disturbing a single window that was already there",
275
+ despues.get("seat") == antes.get("seat") and despues.get("api") == antes.get("api"),
276
+ f"{antes} → {despues}",
277
+ )
278
+ comprueba("· and the city is not opened twice", len(ventanas), 3)
279
+ afirma("· it says what it did", "manual" in salida.stderr, salida.stderr[-300:])
280
+
281
+ # A window whose agent left the card is somebody's work in progress.
282
+ mundo.corre("tmux", "new-window", "-t", sesion, "-n", "viejo")
283
+ salida = abre_la_sesion(mundo)
284
+ afirma(
285
+ "· non-happy: a window nobody's card claims is reported, never closed",
286
+ "viejo" in mundo.ventanas(sesion) and "viejo" in salida.stderr,
287
+ salida.stderr[-400:],
288
+ )
289
+
290
+ # ── 7. the chair reaches into somebody's repo ───────────────────────
291
+ print(" and the chair is a chair")
292
+ del_lanzador = mundo.entorno_de_la_sesion(sesion)
293
+ afirma(
294
+ "· the launcher tells the window which city it belongs to",
295
+ del_lanzador.get("AGENTS_CITY_DATA") == mundo.datos,
296
+ str(del_lanzador),
297
+ )
298
+ propio, _ = llama_al_gancho(mundo, sesion, "Read", {"file_path": ficha})
299
+ comprueba("· happy: its own card is its own business", decision(propio), "")
300
+ ajeno, bruto = llama_al_gancho(
301
+ mundo, sesion, "Bash",
302
+ {"command": f"cd {mundo.repo} && grep -rn router app"},
303
+ )
304
+ comprueba("· non-happy: its agent's repo is not", decision(ajeno), "deny")
305
+ afirma(
306
+ "· and the refusal hands over the house, the role and the way to ask",
307
+ "api" in motivo(ajeno) and "dev" in motivo(ajeno)
308
+ and "--member api" in motivo(ajeno),
309
+ motivo(ajeno)[:400] or bruto.stderr[-300:],
310
+ )
311
+ otro, _ = llama_al_gancho(mundo, sesion, "Read",
312
+ {"file_path": os.path.join(mundo.papeles, "guia.md")})
313
+ afirma(
314
+ "· non-happy: and a folder of documents is ground too, with its own owner",
315
+ decision(otro) == "deny" and "manual" in motivo(otro),
316
+ motivo(otro)[:200],
317
+ )
318
+ puerta, _ = llama_al_gancho(
319
+ mundo, sesion, "Bash",
320
+ {"command": f'agents-city committee open --member api --question "about {mundo.repo}"'},
321
+ )
322
+ comprueba("· happy: and asking is never the thing that is stopped",
323
+ decision(puerta), "")
324
+
325
+ # ── 8. the refusal is on disk, and readable from the browser ────────
326
+ st, diario = pide(puerto, "/api/diario")
327
+ lineas = [x for x in (diario.get("lineas") or diario.get("entradas") or [])
328
+ if x.get("tipo") == "alcance"]
329
+ afirma(
330
+ "· the refusal is in the journal the owner can send me",
331
+ st == 200 and any(x.get("agente") == "api" for x in lineas),
332
+ json.dumps(diario)[:400],
333
+ )
334
+
335
+ # ── 9. and the owner can open the chair's hands ─────────────────────
336
+ r = mundo.corre("bash", os.path.join(RAIZ, "bin", "seat"),
337
+ "--city", mundo.datos, "--seat-reach", "open")
338
+ abierto, _ = llama_al_gancho(
339
+ mundo, sesion, "Bash", {"command": f"grep -rn router {mundo.repo}"})
340
+ afirma(
341
+ "· happy: `--seat-reach open` is the owner's call, and it takes effect",
342
+ r.returncode == 0 and decision(abierto) == "",
343
+ f"{r.stdout}{r.stderr}\n{motivo(abierto)[:200]}",
344
+ )
345
+ r = mundo.corre("bash", os.path.join(RAIZ, "bin", "seat"),
346
+ "--city", mundo.datos, "--seat-reach", "closed")
347
+ cerrado, _ = llama_al_gancho(
348
+ mundo, sesion, "Bash", {"command": f"grep -rn router {mundo.repo}"})
349
+ comprueba("· and closing it again puts the boundary back",
350
+ decision(cerrado), "deny")
351
+
352
+ # ── 10. and after all of it, the city still describes itself ────────
353
+ st, estado = pide(puerto, "/api/estado")
354
+ nombres = sorted(a["name"] for a in estado.get("agents", []))
355
+ comprueba("· the city ends the day with the two houses it was given",
356
+ (st, nombres), (200, ["api", "manual"]))
357
+ finally:
358
+ servidor.shutdown()
359
+ servidor.server_close()
360
+ serve.cities.REGISTRO = registro_previo
361
+ for k, v in previo.items():
362
+ if v is None:
363
+ os.environ.pop(k, None)
364
+ else:
365
+ os.environ[k] = v
366
+ mundo.limpia()
367
+ return resumen("e2e")
368
+
369
+
370
+ if __name__ == "__main__":
371
+ sys.exit(main())