agents-city 0.5.5 → 0.5.7

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "city",
11
11
  "source": "./plugin",
12
- "version": "0.5.5",
12
+ "version": "0.5.7",
13
13
  "description": "Operate one autonomous city seat, its domain, role, repo support agents, goal, recognised skills and explicit roads to other cities."
14
14
  }
15
15
  ]
package/README.es.md CHANGED
@@ -1828,14 +1828,22 @@ global del plugin porque varias ciudades pueden ejecutarse a la vez.
1828
1828
  sexta pregunta del asistente o con `agents-city seat --seat-yolo on|off`;
1829
1829
  `--no-yolo` sigue frenando la sesión entera.
1830
1830
 
1831
- `seat_reach` dice si la silla tiene manos dentro de los mounts de sus agentes.
1832
- Está `closed` mientras no escribas `open`, y cerrado significa que una llamada a
1833
- una herramienta que cae sobre un repo, un worktree o una carpeta de documentos
1834
- que montó un agente se rechaza, con el nombre de quien es su dueño, su rol y el
1835
- comando que le pregunta. La silla conserva la carpeta de la ciudad, las
1836
- carreteras y el registro. Existe porque el fallo que evita no deja rastro: un
1837
- asiento que lee el código y responde es idéntico a uno que consultó a sus
1838
- especialistas.
1831
+ `seat_reach` dice si la silla trabaja con sus propias manos. Está `closed`
1832
+ mientras no escribas `open`. Cerrado significa que un asiento tiene las
1833
+ herramientas de una silla y ninguna más: su carpeta de ciudad, las puertas de
1834
+ este producto (`agents-city …`, que es toda la shell que tiene), el bus de la
1835
+ ciudad, y pensar en voz alta. Una carpeta fuera de la ciudad, un comando que no
1836
+ es una puerta, una búsqueda, una descarga o el MCP de un proveedor se rechazan
1837
+ por su nombre, diciendo a quién preguntar.
1838
+
1839
+ Esos tres últimos son la razón de que esto vaya de herramientas y no de
1840
+ carpetas. Un asiento al que le piden una decisión de producto puede traerse sus
1841
+ propias analíticas, responder bien, no pisar el terreno de nadie — y dejar fuera
1842
+ de la conversación a todos los especialistas que le diste. Ese fallo no deja
1843
+ rastro: es idéntico a un asiento que consultó a su ciudad. Cuando llega una
1844
+ pregunta, al asiento se le pone además delante las dos listas — sus agentes, y
1845
+ las ciudades de sus carreteras con el rol que cada una dice tener — porque a
1846
+ veces la respuesta no está ni en sus carpetas ni en su ciudad.
1839
1847
 
1840
1848
  ### `<owner>.md`: ficha del asiento
1841
1849
 
package/README.md CHANGED
@@ -1808,13 +1808,20 @@ launches the chair itself without permission prompts — set at the wizard's
1808
1808
  sixth question or with `agents-city seat --seat-yolo on|off`; `--no-yolo`
1809
1809
  still brakes the whole session.
1810
1810
 
1811
- `seat_reach` says whether the chair has hands inside its agents' mounts. It is
1812
- `closed` unless you write `open`, and closed means a tool call landing on a
1813
- repo, worktree or document folder an agent mounted is refused, with the owner's
1814
- name, their role and the one command that asks them. The chair keeps the city
1815
- folder, the roads and the record. This exists because the failure it prevents is
1816
- silent: a seat that reads the code and answers looks exactly like a seat that
1817
- consulted its specialists.
1811
+ `seat_reach` says whether the chair works with its own hands. It is `closed`
1812
+ unless you write `open`. Closed means a seat holds a chair's tools and nothing
1813
+ else: its own city folder, this product's own doors (`agents-city …` that is
1814
+ the whole shell it has), the city bus, and thinking out loud. A folder outside
1815
+ the city, a shell command that is not a door, a search, a fetch or a vendor's
1816
+ MCP server are refused by name, with who to ask instead.
1817
+
1818
+ The last three are the reason this is about tools and not folders. A seat asked
1819
+ for a product decision can pull its own analytics, answer well, trespass on
1820
+ nothing — and leave every specialist it was given out of the conversation. That
1821
+ failure is silent: it looks exactly like a seat that consulted its city. When a
1822
+ question arrives, the seat is also handed both rosters — its agents, and the
1823
+ cities on its roads with the role each one says it has — because the answer is
1824
+ sometimes in neither its folders nor its own city.
1818
1825
 
1819
1826
  ### `<owner>.md`: seat card
1820
1827
 
package/bin/hall.html CHANGED
@@ -386,6 +386,9 @@ input[type=text]:focus,select:focus{outline:none;border-color:var(--lampara)}
386
386
  /* ── the guided first run ────────────────────────────────────────────────── */
387
387
  .bv{display:flex;flex-direction:column;gap:26px;max-width:min(760px,100%)}
388
388
  .bv .prosa{max-width:100%}
389
+ /* The consequence of a choice, under the button that makes it: quieter than the
390
+ question and still legible, because a warning nobody reads is decoration. */
391
+ .bv .prosa.apunte{font-size:13px;margin-top:10px;opacity:.82}
389
392
  .bv code.mono{word-break:break-all}
390
393
  /* The live rail has nothing to show before a city exists, and it costs half
391
394
  the screen exactly when the guide needs it. */
package/bin/navegador.mjs CHANGED
@@ -35,11 +35,31 @@ function dondeEstaChrome() {
35
35
  return CANDIDATOS.find((p) => existsSync(p)) || '';
36
36
  }
37
37
 
38
- /** Chrome prints its DevTools endpoint on stderr; that is the handshake. */
39
- function esperaEndpoint(proceso, limite = 20000) {
38
+ /** Chrome prints its DevTools endpoint on stderr; that is the handshake.
39
+ *
40
+ * Sixty seconds, not twenty. This is a browser cold-starting on a shared CI
41
+ * runner that is also finishing two thousand other checks, and twenty seconds
42
+ * was a coin toss we kept winning until we did not. Waiting longer costs
43
+ * nothing when it works, because the wait ends at the handshake.
44
+ *
45
+ * And a timeout now carries whatever Chrome DID say. "chrome never announced
46
+ * its port" is a sentence about the observer: it names what we stopped waiting
47
+ * for and drops the one piece of evidence — a missing library, a sandbox
48
+ * refusal, a profile it could not write — that says which of those it was.
49
+ */
50
+ function esperaEndpoint(proceso, limite = Number(process.env.CITY_BROWSER_WAIT_MS) || 60000) {
40
51
  return new Promise((listo, falla) => {
41
52
  let visto = '';
42
- const reloj = setTimeout(() => falla(new Error('chrome never announced its port')), limite);
53
+ const reloj = setTimeout(
54
+ () =>
55
+ falla(
56
+ new Error(
57
+ `chrome never announced its port in ${limite / 1000}s` +
58
+ (visto.trim() ? `; it said: ${visto.trim().slice(-400)}` : ', and said nothing'),
59
+ ),
60
+ ),
61
+ limite,
62
+ );
43
63
  proceso.stderr.on('data', (trozo) => {
44
64
  visto += trozo.toString();
45
65
  const m = visto.match(/ws:\/\/[^\s]+/);
@@ -65,7 +85,9 @@ class Cdp {
65
85
  const espera = this.pendientes.get(mensaje.id);
66
86
  if (!espera) return;
67
87
  this.pendientes.delete(mensaje.id);
68
- mensaje.error ? espera.falla(new Error(JSON.stringify(mensaje.error))) : espera.listo(mensaje.result);
88
+ mensaje.error
89
+ ? espera.falla(new Error(JSON.stringify(mensaje.error)))
90
+ : espera.listo(mensaje.result);
69
91
  });
70
92
  }
71
93
 
@@ -73,7 +95,9 @@ class Cdp {
73
95
  const socket = new WebSocket(url);
74
96
  await new Promise((listo, falla) => {
75
97
  socket.addEventListener('open', listo, { once: true });
76
- socket.addEventListener('error', () => falla(new Error('cannot reach chrome')), { once: true });
98
+ socket.addEventListener('error', () => falla(new Error('cannot reach chrome')), {
99
+ once: true,
100
+ });
77
101
  });
78
102
  return new Cdp(socket);
79
103
  }
@@ -102,7 +126,9 @@ const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
102
126
 
103
127
  let fallos = 0;
104
128
  function comprueba(texto, bien, detalle = '') {
105
- console.log(`${bien ? ' ok ·' : ' FAIL·'} ${texto}${bien || !detalle ? '' : `\n ${detalle}`}`);
129
+ console.log(
130
+ `${bien ? ' ok ·' : ' FAIL·'} ${texto}${bien || !detalle ? '' : `\n ${detalle}`}`,
131
+ );
106
132
  if (!bien) fallos += 1;
107
133
  }
108
134
 
@@ -195,7 +221,11 @@ async function main() {
195
221
  test: boton('.rpgTest'),
196
222
  };
197
223
  })()`);
198
- comprueba('the houses view renders its sheets', (abierto?.fichas ?? 0) > 0, JSON.stringify(abierto));
224
+ comprueba(
225
+ 'the houses view renders its sheets',
226
+ (abierto?.fichas ?? 0) > 0,
227
+ JSON.stringify(abierto),
228
+ );
199
229
  for (const [control, etiqueta] of [
200
230
  ['alta', 'the build-a-house button'],
201
231
  ['instrucciones', 'the CLAUDE.md / AGENTS.md editors'],
@@ -316,8 +346,11 @@ async function main() {
316
346
  })()`);
317
347
  comprueba(
318
348
  'the house form walks the disk — nothing offered, you pick what you want',
319
- (casa?.filas ?? 0) > 0 && !!casa?.sube && !!casa?.tomaAqui &&
320
- casa.dondeDespues !== casa.dondeAntes && (casa?.elegidas ?? 0) > 0,
349
+ (casa?.filas ?? 0) > 0 &&
350
+ !!casa?.sube &&
351
+ !!casa?.tomaAqui &&
352
+ casa.dondeDespues !== casa.dondeAntes &&
353
+ (casa?.elegidas ?? 0) > 0,
321
354
  JSON.stringify(casa),
322
355
  );
323
356
  comprueba(
@@ -468,13 +501,16 @@ async function main() {
468
501
  })()`);
469
502
  comprueba(
470
503
  'thirty connected people use one recipient picker, not thirty message boxes',
471
- reception?.navRole === 'button' && reception?.navTabIndex === '0' &&
472
- reception?.composers === 1 && reception?.recipients === 30,
504
+ reception?.navRole === 'button' &&
505
+ reception?.navTabIndex === '0' &&
506
+ reception?.composers === 1 &&
507
+ reception?.recipients === 30,
473
508
  JSON.stringify(reception),
474
509
  );
475
510
  comprueba(
476
511
  'pending messages stay above composing and use distinct route/reject language',
477
- reception?.messages === 1 && reception?.reviewFirst === true &&
512
+ reception?.messages === 1 &&
513
+ reception?.reviewFirst === true &&
478
514
  /route|dirigir/i.test(reception?.route ?? '') &&
479
515
  /reason|motivo/i.test(reception?.rejection ?? ''),
480
516
  JSON.stringify(reception),
@@ -687,8 +723,11 @@ async function main() {
687
723
  })()`);
688
724
  comprueba(
689
725
  'the language switch really translates the page, and remembers it',
690
- !!lengua && !lengua.falta && lengua.despues !== lengua.antes &&
691
- /casas|resumen|mapa/i.test(lengua.despues) && lengua.guardado === lengua.lang &&
726
+ !!lengua &&
727
+ !lengua.falta &&
728
+ lengua.despues !== lengua.antes &&
729
+ /casas|resumen|mapa/i.test(lengua.despues) &&
730
+ lengua.guardado === lengua.lang &&
692
731
  lengua.etiqueta !== lengua.etiquetaAntes,
693
732
  JSON.stringify(lengua),
694
733
  );
@@ -698,8 +737,14 @@ async function main() {
698
737
  JSON.stringify(lengua?.marca),
699
738
  );
700
739
 
701
- const errores = await cdp.evalua('window.__erroresDePagina ? window.__erroresDePagina.length : 0');
702
- comprueba('the page raised no uncaught errors while we drove it', errores === 0, String(errores));
740
+ const errores = await cdp.evalua(
741
+ 'window.__erroresDePagina ? window.__erroresDePagina.length : 0',
742
+ );
743
+ comprueba(
744
+ 'the page raised no uncaught errors while we drove it',
745
+ errores === 0,
746
+ String(errores),
747
+ );
703
748
  } finally {
704
749
  proceso.kill('SIGKILL');
705
750
  }
package/bin/test CHANGED
@@ -92,6 +92,7 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
92
92
  "plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
93
93
  "plugin/scripts/demos.py", "plugin/scripts/arnes.py",
94
94
  "plugin/scripts/diario.py", "plugin/scripts/alcance.py",
95
+ "plugin/scripts/consulta.py",
95
96
  "plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
96
97
  "plugin/scripts/deliberations.py",
97
98
  "plugin/scripts/runtime_processes.py",
@@ -32,7 +32,9 @@ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
32
32
 
33
33
  import alcance # noqa: E402
34
34
  import cities # noqa: E402
35
+ import consulta # noqa: E402
35
36
  import diario # noqa: E402
37
+ import roads # noqa: E402
36
38
  import seat as S # noqa: E402
37
39
  import workspace # noqa: E402
38
40
  from testlib import afirma, comprueba, resumen, roster # noqa: E402
@@ -82,6 +84,10 @@ def juzga(datos, herramienta, entrada, cwd=None, **extra):
82
84
  )
83
85
 
84
86
 
87
+ def decision_de(veredicto):
88
+ return ((veredicto or {}).get("hookSpecificOutput") or {}).get("permissionDecision", "")
89
+
90
+
85
91
  def razon(veredicto):
86
92
  return ((veredicto or {}).get("hookSpecificOutput") or {}).get("permissionDecisionReason", "")
87
93
 
@@ -113,14 +119,21 @@ def lo_que_sigue_pudiendo(datos, api):
113
119
  is None,
114
120
  )
115
121
  afirma(
116
- "· happy: a shell command that names no place at all",
117
- juzga(datos, "Bash", {"command": "git status"}) is None,
122
+ "· happy: and its own record, written not just read",
123
+ juzga(datos, "Write", {"file_path": os.path.join(datos, "roles", "cpto.md")}) is None,
124
+ )
125
+ afirma(
126
+ "· happy: the bus is its voice, so its own bus tools pass",
127
+ all(juzga(datos, t, {}) is None for t in
128
+ ("mcp__city-bus__bus_roster", "mcp__plugin_city_city-bus__bus_send")),
118
129
  )
119
130
  afirma(
120
- "· happy: somewhere nobody in this city owns",
121
- juzga(datos, "Read", {"file_path": os.path.join(datos, "..", "nada.txt")}) is None,
131
+ "· happy: and thinking out loud costs nobody anything",
132
+ all(juzga(datos, t, {}) is None
133
+ for t in ("TodoWrite", "Skill", "SlashCommand", "ExitPlanMode")),
122
134
  )
123
135
 
136
+
124
137
  # The refusal recommends `agents-city committee open --question "..."`, and a
125
138
  # brief about a repo names that repo. A guard that denies its own remedy is
126
139
  # a guard that just stops the seat.
@@ -137,28 +150,23 @@ def lo_que_sigue_pudiendo(datos, api):
137
150
  "· happy: and neither is the repo's own committee door",
138
151
  juzga(datos, "Bash", {"command": f"./bin/committee open --member api # {api}"}) is None,
139
152
  )
140
- # Prose that happens to contain a path is prose.
153
+ # A brief quotes the folder it is about. That prose must survive inside the
154
+ # one shell a chair has, or the refusal would be denying its own remedy.
141
155
  afirma(
142
- "· happy: a quoted sentence that mentions a folder is not a hand in it",
143
- juzga(datos, "Bash", {"command": f'echo "the answer is somewhere under {api}/app"'})
144
- is None,
156
+ "· happy: a door whose question quotes a folder is still a door",
157
+ juzga(datos, "Bash", {
158
+ "command": f'agents-city committee open --member api '
159
+ f'--question "what should change under {api}/app"'}) is None,
145
160
  )
146
161
  afirma(
147
- "· happy: even when the sentence starts with the folder",
148
- juzga(datos, "Bash", {"command": f'echo "{api}/app is where it lives"'}) is None,
162
+ "· happy: even when the question starts with the folder",
163
+ juzga(datos, "Bash", {
164
+ "command": f'agents-city committee open --member api '
165
+ f'--question "{api}/app is where it lives"'}) is None,
149
166
  )
150
167
  afirma(
151
168
  "· happy: and a sibling folder whose name merely starts the same way",
152
- juzga(datos, "Read", {"file_path": f"{api}-viejo/router.rb"}) is None,
153
- )
154
- # The other side of that tiebreaker: a folder with a space in its name is a
155
- # real folder, and plenty of people have one.
156
- con_espacio = os.path.join(os.path.dirname(api), "api", "app", "mis notas.md")
157
- open(con_espacio, "w").write("# notas\n")
158
- afirma(
159
- "· non-happy: a real path with a space in it is still that agent's ground",
160
- juzga(datos, "Bash", {"command": f'cat "{con_espacio}"'}) is not None,
161
- con_espacio,
169
+ juzga(datos, "Read", {"file_path": os.path.join(datos, "roles")}) is None,
162
170
  )
163
171
 
164
172
  afirma(
@@ -265,6 +273,64 @@ def lo_que_ya_no_puede(datos, api, docs):
265
273
  )
266
274
 
267
275
 
276
+ # ── a chair holds a chair's tools ────────────────────────────────────────────
277
+
278
+
279
+ def las_manos_de_una_silla(datos, api):
280
+ """The half that folders never covered.
281
+
282
+ A seat asked for a product decision read nobody's files, trespassed on
283
+ nothing, called two of its vendor's SEO tools and answered alone — while
284
+ three configured specialists never heard the question. A guard that only
285
+ watches folders records that as a clean turn, which is why the rule is now
286
+ what a chair IS rather than where it may go.
287
+ """
288
+ print(" and it holds a chair's tools, not the work's")
289
+
290
+ fuera_de_la_ciudad = [
291
+ ("a search", "WebSearch", {"query": "menu information architecture"}),
292
+ ("a fetch", "WebFetch", {"url": "https://example.invalid/x"}),
293
+ ("somebody else's MCP server", "mcp__claude_ai_Nexo__seo_get_site_context", {}),
294
+ ("another vendor's, spelled differently", "mcp__slack__slack_send_message", {}),
295
+ ("hiring hands that are not its houses", "Task", {"prompt": "go and find out"}),
296
+ ("a shell that is not a door", "Bash", {"command": "ls -la"}),
297
+ ("a shell that leaves the machine", "Bash", {"command": "curl https://example.invalid"}),
298
+ ("a file that belongs to nobody here", "Read", {"file_path": "/etc/hosts"}),
299
+ ]
300
+ for nombre, herramienta, entrada in fuera_de_la_ciudad:
301
+ v = juzga(datos, herramienta, entrada)
302
+ afirma(f"· non-happy: {nombre}", (v or {}) and decision_de(v) == "deny",
303
+ f'{herramienta} {json.dumps(entrada)[:80]}')
304
+
305
+ # The one from the report, in full: nothing is trespassed and the answer is
306
+ # still one model's.
307
+ texto = razon(juzga(datos, "mcp__claude_ai_Nexo__seo_get_site_context", {}))
308
+ afirma("· the refusal says why a clean turn is still the wrong turn",
309
+ "leaves no trace" in texto, texto[:300])
310
+ afirma("· and names everyone this city has, with their roles",
311
+ "api" in texto and "dev" in texto and "manual" in texto and "seo" in texto,
312
+ texto[:400])
313
+ afirma("· and how to ask one of them",
314
+ "agents-city committee open" in texto and "--member" in texto, texto[:400])
315
+ afirma("· and that the answer may not be in this city at all",
316
+ "bus_roster" in texto and "bus_send" in texto, texto[-500:])
317
+
318
+ afirma("· non-happy: and a bare shell says what a chair's shell is",
319
+ "agents-city committee" in razon(juzga(datos, "Bash", {"command": "ls -la"})))
320
+
321
+ # A chair with nobody to ask is a person with a terminal.
322
+ sola = tempfile.mkdtemp()
323
+ with open(os.path.join(sola, "city.yml"), "w", encoding="utf-8") as f:
324
+ f.write("owner: ana\nname: sola\nslug: sola\nid: city-alcance-sola\n")
325
+ S.escribe_ficha(os.path.join(sola, "ana.md"), "ana", "cpto", [])
326
+ afirma(
327
+ "· happy: a city with no agents yet does not come with its seat bricked",
328
+ juzga(sola, "WebSearch", {"query": "anything"}) is None
329
+ and juzga(sola, "Bash", {"command": "ls"}) is None,
330
+ )
331
+ shutil.rmtree(sola, ignore_errors=True)
332
+
333
+
268
334
  # ── the owner decides, and the guard never decides for them ──────────────────
269
335
 
270
336
 
@@ -274,7 +340,8 @@ def la_puerta_del_dueno(datos, api):
274
340
  afirma("· closed is the default", juzga(datos, "Read", dentro) is not None)
275
341
  afirma(
276
342
  "· happy: CITY_SEAT_REACH=open gives the chair its hands, now",
277
- juzga(datos, "Read", dentro, CITY_SEAT_REACH="open") is None,
343
+ juzga(datos, "Read", dentro, CITY_SEAT_REACH="open") is None
344
+ and juzga(datos, "WebSearch", {"query": "x"}, CITY_SEAT_REACH="open") is None,
278
345
  )
279
346
  cities.pon_clave(datos, "seat_reach", "open")
280
347
  try:
@@ -291,6 +358,77 @@ def la_puerta_del_dueno(datos, api):
291
358
  )
292
359
 
293
360
 
361
+ # ── and it is told who it has, when the question arrives ─────────────────────
362
+
363
+
364
+ def el_momento_de_decidir(datos):
365
+ """Stopping the chair is half of it; the other half is knowing who to ask.
366
+
367
+ A refusal that arrives after the seat has started is a correction. The plan
368
+ has to happen where the question lands — and it has to carry BOTH rosters,
369
+ because a city can be missing the answer in two ways: no agent here does
370
+ that work, or the answer belongs to somebody else's city entirely.
371
+ """
372
+ print(" and it is told who it has, at the moment the question arrives")
373
+ roads.escribe(datos, [{
374
+ "id": "road_0000000000000000000000000000abcd",
375
+ "address": "bea/comunidad",
376
+ "owner": "bea",
377
+ "name": "comunidad",
378
+ "role": "ux",
379
+ "domain": "marketing",
380
+ "local": True,
381
+ }])
382
+ nota = consulta.contexto(datos)
383
+ afirma("· it names every agent in this city, with its role",
384
+ "api" in nota and "dev" in nota and "manual" in nota and "seo" in nota, nota)
385
+ afirma("· and how much ground each of them holds",
386
+ "1 folder" in nota, nota)
387
+ afirma("· it names the cities on the roads too, with what they do",
388
+ "bea/comunidad" in nota and "ux" in nota and "marketing" in nota, nota)
389
+ afirma("· and says a road's role came from a note, not from that city",
390
+ "note" in nota, nota)
391
+ afirma("· it offers both routes, because the answer may be in neither place",
392
+ "agents-city committee open" in nota and "bus_send" in nota, nota)
393
+ afirma("· and says that deciding nobody is a decision, not a shortcut",
394
+ "concerns nobody" in nota, nota[-400:])
395
+
396
+ def por_el_gancho(prompt, **extra):
397
+ env = dict(os.environ)
398
+ env.update({"CLAUDE_PLUGIN_ROOT": os.path.join(RAIZ, "plugin"),
399
+ "AGENTS_CITY_DATA": datos, "CITY_BUS_ACTOR": "seat"})
400
+ env.update(extra)
401
+ r = subprocess.run(
402
+ ["/bin/bash", os.path.join(RAIZ, "plugin", "hooks", "who-does-this-concern.sh")],
403
+ input=json.dumps({"prompt": prompt}), capture_output=True, text=True, env=env,
404
+ )
405
+ try:
406
+ return json.loads(r.stdout or "{}"), r
407
+ except ValueError:
408
+ return {}, r
409
+
410
+ salida, r = por_el_gancho("I want to improve the menu, people cannot find things")
411
+ afirma("· the hook itself puts it in front of the seat",
412
+ "api" in ((salida.get("hookSpecificOutput") or {}).get("additionalContext") or ""),
413
+ f"{r.stdout[:200]} {r.stderr[-200:]}")
414
+ salida, _ = por_el_gancho("/city:round")
415
+ comprueba("· non-happy: a slash command already carries its own instructions",
416
+ salida, {})
417
+ salida, _ = por_el_gancho("sigue")
418
+ comprueba("· non-happy: and a two-word reply is not a question to route", salida, {})
419
+ salida, _ = por_el_gancho("I want to improve the menu, people cannot find things",
420
+ CITY_BUS_ACTOR="api")
421
+ comprueba("· non-happy: a house is not chairing anything", salida, {})
422
+ salida, _ = por_el_gancho("I want to improve the menu, people cannot find things",
423
+ AGENTS_CITY_DATA="")
424
+ comprueba("· non-happy: and with no city there is nobody to name", salida, {})
425
+ roads.escribe(datos, [])
426
+
427
+ hooks = json.load(open(os.path.join(RAIZ, "plugin", "hooks", "hooks.json")))
428
+ afirma("· and Claude is told to run it when a prompt arrives",
429
+ "who-does-this-concern.sh" in json.dumps(hooks["hooks"]["UserPromptSubmit"]))
430
+
431
+
294
432
  # ── a guard that breaks a turn is worse than no guard ────────────────────────
295
433
 
296
434
 
@@ -356,7 +494,8 @@ def nunca_rompe_el_turno(datos, api):
356
494
  "· non-happy: a tool_input that is not an object",
357
495
  juzga(datos, "Read", "no soy un objeto") is None,
358
496
  )
359
- afirma("· non-happy: an empty command", juzga(datos, "Bash", {"command": ""}) is None)
497
+ afirma("· non-happy: an empty command is not a door either",
498
+ juzga(datos, "Bash", {"command": ""}) is not None)
360
499
  afirma(
361
500
  "· non-happy: an unbalanced quote still gets read",
362
501
  juzga(datos, "Bash", {"command": f"grep -r ' {api}/app"}) is not None,
@@ -388,12 +527,12 @@ def nunca_rompe_el_turno(datos, api):
388
527
  def queda_escrito(datos, api):
389
528
  print(" every refusal is on disk before anybody has to remember it")
390
529
  antes = len(diario.lee(datos))
391
- juzga(datos, "Bash", {"command": f"grep -rn router {api}"})
530
+ juzga(datos, "Grep", {"pattern": "router", "path": api})
392
531
  lineas = [x for x in diario.lee(datos) if x.get("tipo") == "alcance"]
393
532
  afirma("· the refusal is journalled", len(diario.lee(datos)) > antes and lineas, str(lineas))
394
533
  ultima = lineas[-1]
395
534
  comprueba("· with the agent whose ground it was", ultima.get("agente"), "api")
396
- comprueba("· and the tool that tried", ultima.get("herramienta"), "Bash")
535
+ comprueba("· and the tool that tried", ultima.get("herramienta"), "Grep")
397
536
  afirma("· and the exact path", api in str(ultima.get("ruta")), str(ultima))
398
537
 
399
538
 
@@ -440,8 +579,9 @@ def el_gancho_de_verdad(datos, api):
440
579
  # nobody registered is a file.
441
580
  hooks = json.load(open(os.path.join(RAIZ, "plugin", "hooks", "hooks.json")))
442
581
  texto = json.dumps(hooks["hooks"]["PreToolUse"])
443
- afirma("· and Claude is told to run it before every tool that names a place",
444
- "ask-the-house.sh" in texto and "Bash" in texto and "Grep" in texto, texto[:400])
582
+ afirma("· and Claude is told to run it before EVERY tool, not a list of them",
583
+ "ask-the-house.sh" in texto and '"matcher": "*"' in texto,
584
+ 'a list of tools is a list of the ways somebody thought of')
445
585
 
446
586
 
447
587
  def main():
@@ -451,6 +591,8 @@ def main():
451
591
  try:
452
592
  lo_que_sigue_pudiendo(datos, api)
453
593
  lo_que_ya_no_puede(datos, api, docs)
594
+ las_manos_de_una_silla(datos, api)
595
+ el_momento_de_decidir(datos)
454
596
  la_puerta_del_dueno(datos, api)
455
597
  nunca_se_lleva_la_ciudad_por_delante(base, api)
456
598
  nunca_rompe_el_turno(datos, api)
package/bin/test-e2e.py CHANGED
@@ -298,8 +298,8 @@ def main():
298
298
  propio, _ = llama_al_gancho(mundo, sesion, "Read", {"file_path": ficha})
299
299
  comprueba("· happy: its own card is its own business", decision(propio), "")
300
300
  ajeno, bruto = llama_al_gancho(
301
- mundo, sesion, "Bash",
302
- {"command": f"cd {mundo.repo} && grep -rn router app"},
301
+ mundo, sesion, "Read",
302
+ {"file_path": os.path.join(mundo.repo, "app", "router.rb")},
303
303
  )
304
304
  comprueba("· non-happy: its agent's repo is not", decision(ajeno), "deny")
305
305
  afirma(
@@ -315,6 +315,18 @@ def main():
315
315
  decision(otro) == "deny" and "manual" in motivo(otro),
316
316
  motivo(otro)[:200],
317
317
  )
318
+ # The half that folders never covered: work that trespasses on nobody.
319
+ vendedor, _ = llama_al_gancho(
320
+ mundo, sesion, "mcp__claude_ai_Nexo__seo_get_site_context", {})
321
+ afirma(
322
+ "· non-happy: and neither is a vendor's tool that touches nobody's files",
323
+ decision(vendedor) == "deny" and "api" in motivo(vendedor)
324
+ and "bus_send" in motivo(vendedor),
325
+ motivo(vendedor)[:300],
326
+ )
327
+ suelto, _ = llama_al_gancho(mundo, sesion, "Bash", {"command": "ls -la /"})
328
+ comprueba("· non-happy: nor a shell that is not one of this product's doors",
329
+ decision(suelto), "deny")
318
330
  puerta, _ = llama_al_gancho(
319
331
  mundo, sesion, "Bash",
320
332
  {"command": f'agents-city committee open --member api --question "about {mundo.repo}"'},
@@ -145,9 +145,43 @@ def bundle_al_dia():
145
145
  return "rebuilt from src/hall.ts"
146
146
 
147
147
 
148
+ def un_chrome_que_no_arranca():
149
+ """What the driver says when the browser never answers.
150
+
151
+ This suite has exactly one way to fail without telling anybody anything, and
152
+ CI found it: `chrome never announced its port` names what we stopped waiting
153
+ for and drops the only evidence of why — a missing library, a sandbox
154
+ refusal, a profile it could not write. A stub browser that talks and never
155
+ announces proves the message carries what it heard.
156
+
157
+ CHROME_PATH is the first candidate the driver considers, which is what makes
158
+ this testable without a browser at all.
159
+ """
160
+ print(" and a browser that never starts says why")
161
+ casa = tempfile.mkdtemp()
162
+ falso = os.path.join(casa, "chrome")
163
+ with open(falso, "w", encoding="utf-8") as f:
164
+ f.write("#!/bin/bash\n"
165
+ "echo 'FATAL: could not open display, and this is the useful half' >&2\n"
166
+ "sleep 30\n")
167
+ os.chmod(falso, 0o755)
168
+ salida = subprocess.run(
169
+ ["node", os.path.join(RAIZ, "bin", "navegador.mjs"), "http://127.0.0.1:1/"],
170
+ capture_output=True, text=True, timeout=90,
171
+ env=dict(os.environ, CHROME_PATH=falso, CITY_BROWSER_WAIT_MS="1500"),
172
+ )
173
+ shutil.rmtree(casa, ignore_errors=True)
174
+ todo = salida.stdout + salida.stderr
175
+ afirma("· the timeout names what it waited for",
176
+ "never announced its port" in todo, todo[-300:])
177
+ afirma("· and repeats what the browser actually said",
178
+ "could not open display" in todo, todo[-300:])
179
+
180
+
148
181
  def main():
149
182
  print()
150
183
  print(" the hall, in a browser that clicks")
184
+ un_chrome_que_no_arranca()
151
185
  print(f" · {bundle_al_dia()}")
152
186
  datos = tempfile.mkdtemp()
153
187
  casa = tempfile.mkdtemp()
package/bin/test-seat.py CHANGED
@@ -1521,6 +1521,11 @@ def arranque_escalonado():
1521
1521
  corre(**extra)
1522
1522
  return open(registro).read()
1523
1523
 
1524
+ primera = crudo(CITY_SETTLE="0", CITY_STAGGER="0")
1525
+ afirma(
1526
+ "· happy: a city opened for the first time gets its tmux comforts",
1527
+ "set-option" in primera, primera[:300],
1528
+ )
1524
1529
  log = crudo(FAKE_SESSION="1", FAKE_WINDOWS="seat api", CITY_SETTLE="0", CITY_STAGGER="0")
1525
1530
  afirma(
1526
1531
  "· happy: an agent added to a running city gets its window",
@@ -1542,6 +1547,12 @@ def arranque_escalonado():
1542
1547
  "new-session" not in log,
1543
1548
  log[:600],
1544
1549
  )
1550
+ afirma(
1551
+ "· non-happy: and a running session's tmux options are left exactly as found",
1552
+ "set-option" not in log,
1553
+ "re-applying `set -g mouse on` under a live full-screen app types raw SGR "
1554
+ "sequences into its prompt: " + log[:400],
1555
+ )
1545
1556
  afirma(
1546
1557
  "· the new house is what you are looking at when it opens",
1547
1558
  "select-window" in log and ":docs" in log.split("select-window")[-1][:40],