agents-city 0.5.5 → 0.5.6

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.6",
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/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}"'},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-city",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Run autonomous agent cities: one chair seat, role-aware repo agents, repo-owned skills, and explicit roads.",
5
5
  "bin": {
6
6
  "agents-city": "bin/agents-city.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "city",
3
3
  "displayName": "Agents City",
4
- "version": "0.5.5",
4
+ "version": "0.5.6",
5
5
  "description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
6
6
  "author": {
7
7
  "name": "jlcases",
@@ -4,7 +4,7 @@ argument-hint: ""
4
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
5
5
  ---
6
6
 
7
- Source `${CLAUDE_PLUGIN_ROOT}/scripts/city-env.sh`, then read the owner from
7
+ Read the owner from
8
8
  `$AGENTS_CITY_DATA/city.yml` and that owner's single card.
9
9
 
10
10
  With no requested change, show the current goal: title, signal, command or manual
@@ -7,7 +7,7 @@ allowed-tools: Read, Bash, Glob, Grep, mcp__plugin_city_city-bus__bus_send, mcp_
7
7
  Read the `city` skill's Notices section. Inspect the requested diff and separate
8
8
  observed facts from guesses. Do not notify merely because files changed.
9
9
 
10
- Source `${CLAUDE_PLUGIN_ROOT}/scripts/city-env.sh`, call `bus_roster`, and choose
10
+ Call `bus_roster` and choose
11
11
  only explicit road destinations whose role/domain may be affected. If none match,
12
12
  say so and send nothing.
13
13
 
@@ -4,7 +4,7 @@ argument-hint: "[--no-yolo] [--only r1,r2]"
4
4
  allowed-tools: Read, Bash, Glob, Grep
5
5
  ---
6
6
 
7
- Source `${CLAUDE_PLUGIN_ROOT}/scripts/city-env.sh` and run:
7
+ Run:
8
8
 
9
9
  ```bash
10
10
  "$CLAUDE_PLUGIN_ROOT/scripts/city-session.sh" "$AGENTS_CITY_USER" --claude $ARGUMENTS
@@ -4,7 +4,7 @@ argument-hint: "[domain | role | repos | agent-roles | goal | engines | roads |
4
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
5
5
  ---
6
6
 
7
- Source `${CLAUDE_PLUGIN_ROOT}/scripts/city-env.sh` first. Show the active city
7
+ The seat window already carries the active city in its environment. Show it
8
8
  address and path before changing anything.
9
9
 
10
10
  - `domain`, `role`, `repos`, `agent-roles`, `goal`, `engines`: run the corresponding option of
@@ -7,7 +7,7 @@ allowed-tools: Read, Bash, Glob, Grep, mcp__plugin_city_city-bus__bus_roster, mc
7
7
  This command keeps its old name as a compatibility alias, but do not describe a
8
8
  city as a team of people.
9
9
 
10
- 1. Source `${CLAUDE_PLUGIN_ROOT}/scripts/city-env.sh`.
10
+ 1. Read `$AGENTS_CITY_DATA`, which the launcher already set in this window.
11
11
  2. Run `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/cities.py" list`.
12
12
  3. Read this city's `roads.json` and owner card only.
13
13
  4. Call `bus_roster` when available.
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env bash
2
- # PreToolUse / every tool that names a place — the chair's hands.
2
+ # PreToolUse / every tool — the chair's hands.
3
3
  #
4
- # The seat decides; the houses investigate. Left as advice in the skill, that
5
- # lasted exactly as long as it took for reading the repo to be quicker than
6
- # asking the agent who lives in it. So the seat is stopped at the ground it does
7
- # not own, and told whose it is and how to ask.
4
+ # The seat decides; the agents work. Left as advice in the skill, that lasted
5
+ # exactly as long as it took for doing it to be quicker than asking.
8
6
  #
9
- # Only the seat. A house inside its own mounts is where it belongs, and this
10
- # leaves every other runtime untouched.
7
+ # It began as a guard on FOLDERS, and folders turned out to be half the story: a
8
+ # seat asked for a product decision trespassed on nothing, called two of its
9
+ # vendor's SEO tools, and answered alone while three configured specialists never
10
+ # heard the question. So the matcher is every tool, and what is allowed is what a
11
+ # chair is: its own city folder, this product's own doors, its own voice on the
12
+ # bus, and thinking out loud.
13
+ #
14
+ # Only the seat. An agent using its tools is an agent doing its job, and every
15
+ # other runtime is left untouched.
11
16
  set -uo pipefail
12
17
  # Outside a city runtime the whole plugin stays silent (see the guard).
13
18
  . "$(dirname "$0")/solo-en-ciudad.sh"
@@ -22,7 +22,7 @@
22
22
  ]
23
23
  },
24
24
  {
25
- "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit|Read|Grep|Glob",
25
+ "matcher": "*",
26
26
  "hooks": [
27
27
  {
28
28
  "type": "command",
@@ -61,6 +61,15 @@
61
61
  "timeout": 10
62
62
  }
63
63
  ]
64
+ },
65
+ {
66
+ "hooks": [
67
+ {
68
+ "type": "command",
69
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/who-does-this-concern.sh\"",
70
+ "timeout": 15
71
+ }
72
+ ]
64
73
  }
65
74
  ],
66
75
  "Stop": [
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # UserPromptSubmit — the moment a chair decides who it needs.
3
+ #
4
+ # The tool guard stops a seat from doing the work. It does not make the seat know
5
+ # who to ask, and a refusal that arrives after it has already started is a
6
+ # correction rather than a plan. This runs where the question lands, and puts
7
+ # both rosters in front of it: the agents in this city, and the other cities on
8
+ # its roads with the role each one says it has.
9
+ #
10
+ # The roads half matters as much as the agents half. A question about a product
11
+ # that competes with somebody else's is not answered by any folder in this city.
12
+ set -uo pipefail
13
+ # Outside a city runtime the whole plugin stays silent (see the guard).
14
+ . "$(dirname "$0")/solo-en-ciudad.sh"
15
+
16
+ # `{}` rather than nothing: an empty hook answer logs a spurious error upstream.
17
+ [ "${CITY_BUS_ACTOR:-}" = "seat" ] || { printf '{}\n'; exit 0; }
18
+ [ -n "${AGENTS_CITY_DATA:-}" ] || { printf '{}\n'; exit 0; }
19
+
20
+ ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
21
+ export CLAUDE_PLUGIN_ROOT="$ROOT"
22
+ exec python3 "$ROOT/scripts/consulta.py"
@@ -58,6 +58,17 @@ MAGIA = re.compile(r"[*?\[]")
58
58
  #: be the thing that stops it asking.
59
59
  PUERTAS = ("agents-city", "committee", "bus", "road", "agents", "seat", "cities", "skills")
60
60
 
61
+ #: Tools that reach into a place. Allowed on the chair's own desk, nowhere else.
62
+ SOBRE_SU_MESA = ("Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Grep", "Glob")
63
+
64
+ #: Tools that produce nothing outside this conversation: a plan, a question, a
65
+ #: command of this product's own. A chair may think out loud.
66
+ SIN_EFECTO = ("TodoWrite", "Skill", "SlashCommand", "ExitPlanMode", "AskUserQuestion")
67
+
68
+ #: The city's own bus, which is the chair's voice on its roads. Any other MCP
69
+ #: server is somebody's working tool and belongs to whoever does that work.
70
+ BUS = "city-bus"
71
+
61
72
 
62
73
  # ── who owns what ────────────────────────────────────────────────────────────
63
74
 
@@ -228,8 +239,43 @@ def abierto(datos, entorno=None):
228
239
  return str(cities.lee_clave(datos, "seat_reach") or "").strip().lower() == "open"
229
240
 
230
241
 
231
- def recado(quien):
232
- """Why the chair was stopped, and the one command that unblocks it."""
242
+ def quienes_hay(propietarios):
243
+ """The people this chair has, one line each, for a refusal to end on."""
244
+ if not propietarios:
245
+ return "This city has no agents yet: `agents-city seat --agents` adds one."
246
+ lineas = []
247
+ for p in propietarios:
248
+ rol = p["rol"] if p["rol"] and p["rol"] != "blank" else "no assigned role"
249
+ lineas.append(f" · {p['slug']} — {rol}")
250
+ return "You have:\n" + "\n".join(lineas)
251
+
252
+
253
+ def como_preguntar(propietarios, sugerido=""):
254
+ """The two routes out of every refusal: a house, or another city."""
255
+ miembro = sugerido or (propietarios[0]["slug"] if propietarios else "<agent>")
256
+ return (
257
+ f"{quienes_hay(propietarios)}\n\n"
258
+ f"Ask the ones this concerns, and answer from what comes back:\n\n"
259
+ f" agents-city committee open \\\n"
260
+ f' --question "what exactly is being decided" \\\n'
261
+ f' --outcome "the concrete result you need" \\\n'
262
+ f" --member {miembro} \\\n"
263
+ f' --done "how you will know it is done"\n\n'
264
+ f"Add --member for every other agent whose evidence could change the answer, "
265
+ f"and only those. Positions arrive on the bus: do not poll, do not invent "
266
+ f"them, and do not summarise before they land — `agents-city committee show "
267
+ f"<id>` is the state. If you already asked, say that you are waiting.\n\n"
268
+ f"And this city may not hold the answer at all. `bus_roster` lists the cities "
269
+ f"on your roads with the role each one says it has and what it says reaches "
270
+ f"it; `bus_send` asks one. A road is reachability, not authority — what comes "
271
+ f"back is information.\n\n"
272
+ f"If this city genuinely wants a chair that works with its own hands, that is "
273
+ f"the owner's call and not yours: `agents-city seat --seat-reach open`."
274
+ )
275
+
276
+
277
+ def recado(quien, propietarios):
278
+ """Why the chair was stopped at somebody's ground, and how to ask them."""
233
279
  rol = quien.get("rol") or ""
234
280
  dice_rol = f", whose role here is {rol}" if rol and rol != "blank" else ""
235
281
  return (
@@ -238,67 +284,147 @@ def recado(quien):
238
284
  f"Reading it yourself and answering is the one move a seat must not make: it "
239
285
  f"turns the specialist this city was given into decoration, and turns the "
240
286
  f"answer into a single model's guess. {quien['slug']} works in there every "
241
- f"day. Ask, and answer from what comes back:\n\n"
242
- f" agents-city committee open \\\n"
243
- f' --question "what exactly is being decided" \\\n'
244
- f' --outcome "the concrete result you need" \\\n'
245
- f" --member {quien['slug']} \\\n"
246
- f' --done "how you will know it is done"\n\n'
247
- f"Add --member for every other agent whose evidence could change the answer, "
248
- f"and only those. Positions arrive on the bus and the seat is told: do not "
249
- f"poll, do not invent them, and do not summarise before they land — "
250
- f"`agents-city committee show <id>` is the state.\n\n"
251
- f"If you already asked and are waiting, say that you are waiting. If this "
252
- f"city genuinely wants a chair that reads for itself, that is the owner's "
253
- f"call and not yours: `agents-city seat --seat-reach open`."
287
+ f"day.\n\n" + como_preguntar(propietarios, quien["slug"])
254
288
  )
255
289
 
256
290
 
257
- def juicio(entrada, entorno=None):
258
- """The hook's answer to one tool call: a refusal, or nothing at all."""
259
- entorno = os.environ if entorno is None else entorno
291
+ def fuera_de_la_mesa(ruta, propietarios):
292
+ """Stopped somewhere that belongs to nobody here, which is still not the chair's."""
293
+ return (
294
+ f"{ruta} is outside this city, and a chair's desk is its own city: the card, "
295
+ f"the roles, the record, the roads. Everywhere else belongs to whoever works "
296
+ f"there.\n\n"
297
+ f"You were not stopped because that path is dangerous. You were stopped "
298
+ f"because reading it yourself is how an answer stops being the city's and "
299
+ f"becomes one model's.\n\n" + como_preguntar(propietarios)
300
+ )
301
+
302
+
303
+ def no_es_una_puerta(orden, propietarios):
304
+ """A chair does not run commands; it opens its own doors."""
305
+ corto = orden if len(orden) <= 120 else orden[:119] + "…"
306
+ return (
307
+ f"`{corto}` is work, and work is what this city has agents for.\n\n"
308
+ f"The shell a chair has is this product's own doors — `agents-city committee`, "
309
+ f"`bus`, `road`, `cities`, `agents`, `seat`, `skills`. Everything else a "
310
+ f"command would do for you, somebody here does better, and their answer comes "
311
+ f"with the evidence yours would not have.\n\n" + como_preguntar(propietarios)
312
+ )
313
+
314
+
315
+ def no_son_tus_manos(herramienta, propietarios):
316
+ """An outside tool: the work itself, arriving through a door with no ground."""
317
+ return (
318
+ f"`{herramienta}` fetches or does. A chair decides, and to decide it asks — "
319
+ f"which is the whole reason this city has more than one seat in it.\n\n"
320
+ f"This one is worth saying plainly because it leaves no trace on anybody's "
321
+ f"folders: a search, a fetch, a vendor's MCP server. Nothing is trespassed "
322
+ f"and the specialists still never hear the question, which is the same "
323
+ f"failure wearing better manners.\n\n" + como_preguntar(propietarios)
324
+ )
325
+
326
+
327
+ def _fuera_de_juego(herramienta, datos, entorno):
328
+ """Whether this call is none of the guard's business, before any file is read.
329
+
330
+ Four ways: it is not the chair calling; there is no city to be the chair of;
331
+ the owner opened the chair's hands on purpose; or the tool produces nothing
332
+ outside this conversation — a plan, a question, one of this product's own
333
+ commands, or the city's own bus, which is the chair's voice and not a tool.
334
+ """
260
335
  if str(entorno.get("CITY_BUS_ACTOR", "")) != "seat":
261
- return None
262
- datos = str(entorno.get("AGENTS_CITY_DATA") or "")
336
+ return True
263
337
  if not datos or not os.path.isdir(datos):
264
- return None
338
+ return True
265
339
  if abierto(datos, entorno):
266
- return None
267
- herramienta = str(entrada.get("tool_name") or "")
340
+ return True
341
+ if herramienta in SIN_EFECTO:
342
+ return True
343
+ return herramienta.startswith("mcp__") and BUS in herramienta
344
+
345
+
346
+ def _sobre_la_mesa(entrada, herramienta, datos, propietarios):
347
+ """A place-reaching tool, judged against the chair's own desk.
348
+
349
+ Returns `(refusal, journal fields)` or None. Inside the city folder there is
350
+ nothing to say: the card, the roles, the record and the roads are what a
351
+ chair is for.
352
+ """
268
353
  sitios = candidatas(herramienta, entrada.get("tool_input"), str(entrada.get("cwd") or ""))
269
- if not sitios:
354
+ fuera = [r for r in sitios if not rutas.dentro_de(r, datos)]
355
+ if not fuera:
270
356
  return None
271
- owner = cities.lee_clave(datos, "owner") or ""
272
- texto = card.lee(os.path.join(datos, f"{owner}.md")).get("texto") or ""
273
- if not texto:
274
- return None
275
- try:
276
- propietarios = duenos(datos, texto)
277
- except (OSError, ValueError):
278
- return None
279
- quien = de_quien(sitios, propietarios)
280
- if not quien:
281
- return None
282
- # Always written down. When this refuses something it should not have, the
283
- # owner should not have to reproduce it to show me — the line is on disk.
284
- diario.apunta(
285
- datos,
286
- "alcance",
287
- herramienta=herramienta,
288
- ruta=quien["ruta"],
289
- agente=quien["slug"],
290
- rol=quien["rol"],
291
- mount=quien["mount"],
292
- )
357
+ quien = de_quien(fuera, propietarios)
358
+ if quien:
359
+ return recado(quien, propietarios), {
360
+ "motivo": "somebody's ground", "ruta": quien["ruta"],
361
+ "agente": quien["slug"], "rol": quien["rol"],
362
+ }
363
+ return fuera_de_la_mesa(fuera[0], propietarios), {
364
+ "motivo": "off the desk", "ruta": fuera[0],
365
+ }
366
+
367
+
368
+ def _niega(motivo):
293
369
  return {
294
370
  "hookSpecificOutput": {
295
371
  "hookEventName": "PreToolUse",
296
372
  "permissionDecision": "deny",
297
- "permissionDecisionReason": recado(quien),
373
+ "permissionDecisionReason": motivo,
298
374
  }
299
375
  }
300
376
 
301
377
 
378
+ def juicio(entrada, entorno=None):
379
+ """The hook's answer to one tool call: a refusal, or nothing at all.
380
+
381
+ The rule is not "stay out of your agents' folders". It is that a chair holds
382
+ a chair's tools: its own city, its own doors, its own voice on the roads.
383
+ Everything else — a shell, a search, somebody's vendor MCP — is the work,
384
+ and the work is what this city has agents for.
385
+
386
+ That distinction is not pedantry. A seat asked for a product decision read no
387
+ file belonging to anybody, trespassed on nothing, called two SEO tools of its
388
+ own and answered alone. Three configured specialists never heard the
389
+ question. A guard that only watches folders records that as a clean turn.
390
+ """
391
+ entorno = os.environ if entorno is None else entorno
392
+ datos = str(entorno.get("AGENTS_CITY_DATA") or "")
393
+ herramienta = str(entrada.get("tool_name") or "")
394
+ if _fuera_de_juego(herramienta, datos, entorno):
395
+ return None
396
+
397
+ owner = cities.lee_clave(datos, "owner") or ""
398
+ texto = card.lee(os.path.join(datos, f"{owner}.md")).get("texto") or ""
399
+ try:
400
+ propietarios = duenos(datos, texto) if texto else []
401
+ except (OSError, ValueError):
402
+ propietarios = []
403
+ # A chair with nobody to ask is a person with a terminal. Every refusal below
404
+ # ends in "ask them" — with no them, it would end in nothing, and a city on
405
+ # its first afternoon would come with its own seat bricked.
406
+ if not propietarios:
407
+ return None
408
+
409
+ def apuntado(recado_, **campos):
410
+ # Always written down. When this refuses something it should not have,
411
+ # the owner should not have to reproduce it to show me.
412
+ diario.apunta(datos, "alcance", herramienta=herramienta, **campos)
413
+ return _niega(recado_)
414
+
415
+ if herramienta == "Bash":
416
+ orden = str((entrada.get("tool_input") or {}).get("command") or "")
417
+ if es_puerta(orden):
418
+ return None
419
+ return apuntado(no_es_una_puerta(orden, propietarios), motivo="not a door")
420
+
421
+ if herramienta in SOBRE_SU_MESA:
422
+ veredicto = _sobre_la_mesa(entrada, herramienta, datos, propietarios)
423
+ return apuntado(veredicto[0], **veredicto[1]) if veredicto else None
424
+
425
+ return apuntado(no_son_tus_manos(herramienta, propietarios), motivo="not the chair's hands")
426
+
427
+
302
428
  def main():
303
429
  try:
304
430
  entrada = json.load(sys.stdin)
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """Who this city has, put in front of the chair at the moment a question arrives.
3
+
4
+ Stopping a seat from doing the work is half of it. The other half is that it has
5
+ to KNOW who to ask, at the moment it would otherwise start answering — and the
6
+ one place that is true is when the question lands.
7
+
8
+ Two rosters, because a city can be missing the answer in two different ways.
9
+ Inside, its agents: each one's role and how many folders it works in. Outside,
10
+ its roads: the other cities, the role each one SAYS it has, what it says reaches
11
+ it, and whether that came from the city itself or from a note somebody wrote
12
+ when they opened the road. A question about a menu that competes with somebody
13
+ else's community is not answered by any repo in this city; it is answered by
14
+ asking the city that owns the community.
15
+
16
+ Deliberately not a committee for every "how's it going". The chair is told to
17
+ decide, and told that deciding nobody is a valid decision it should say out loud
18
+ — an obligatory committee would be the same failure with more ceremony.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+
26
+ import card
27
+ import cities
28
+ import roads
29
+ import workspace
30
+
31
+ #: How long the bus gets to answer before this falls back to the note on disk.
32
+ #: It runs once per prompt, in front of a person who is waiting.
33
+ ESPERA = 4
34
+
35
+ #: A prompt shorter than this is a "sí", a "sigue", a "gracias". Nobody needs a
36
+ #: roster to answer those, and a hook that speaks on every keystroke is noise
37
+ #: that gets turned off.
38
+ CORTO = 24
39
+
40
+ #: What a far city said about itself, cut to something a chair can read at a
41
+ #: glance. The full text is on `bus_roster`.
42
+ RECIBE = 200
43
+
44
+
45
+ def agentes_de(datos):
46
+ """This city's own agents: slug, role and how much ground each holds."""
47
+ owner = cities.lee_clave(datos, "owner") or ""
48
+ texto = card.lee(os.path.join(datos, f"{owner}.md")).get("texto") or ""
49
+ if not texto:
50
+ return []
51
+ try:
52
+ return workspace.agentes(texto, datos)
53
+ except (OSError, ValueError):
54
+ return []
55
+
56
+
57
+ def carreteras(datos, plugin=""):
58
+ """The roads, asked of the bus first and the file second.
59
+
60
+ The bus knows what each far city says about ITSELF — its role, its domain,
61
+ what reaches it — because that city publishes it. `roads.json` only holds
62
+ what whoever opened the road wrote down once, which is hearsay that ages.
63
+ Both are usable; only one of them should be presented as the city's word.
64
+ """
65
+ cliente = os.path.join(plugin or "", "channel", "client.js")
66
+ if os.path.isfile(cliente):
67
+ try:
68
+ salida = subprocess.run(
69
+ ["node", cliente, "bus", "roster"],
70
+ capture_output=True, text=True, timeout=ESPERA,
71
+ env=dict(os.environ, AGENTS_CITY_DATA=datos),
72
+ )
73
+ datos_bus = json.loads(salida.stdout or "null")
74
+ if isinstance(datos_bus, list):
75
+ return datos_bus
76
+ if isinstance(datos_bus, dict) and isinstance(datos_bus.get("result"), list):
77
+ return datos_bus["result"]
78
+ except (OSError, ValueError, subprocess.SubprocessError):
79
+ pass
80
+ try:
81
+ return [dict(c, segun={"role": "this city’s own note"}) for c in roads.lee(datos)]
82
+ except (OSError, ValueError):
83
+ return []
84
+
85
+
86
+ def _linea_de_agente(a):
87
+ rol = a.rol if a.rol and a.rol != "blank" else "no assigned role"
88
+ cuantos = len([m for m in a.mounts if m])
89
+ donde = "no folders of its own" if not cuantos else (
90
+ f"{cuantos} folder{'s' if cuantos != 1 else ''}")
91
+ return f" · {a.slug} — {rol}, {a.clase}, {donde}"
92
+
93
+
94
+ def _linea_de_carretera(c):
95
+ direccion = c.get("address") or c.get("id") or "?"
96
+ rol = c.get("role") or ""
97
+ dominio = c.get("domain") or ""
98
+ segun = c.get("segun") or {}
99
+ quien = segun.get("role") if isinstance(segun, dict) else ""
100
+ dice = f" ({quien})" if quien else ""
101
+ estado = "online" if c.get("online") else "offline — a message queues"
102
+ cabeza = f" · {direccion}"
103
+ if rol or dominio:
104
+ cabeza += f" — {rol or 'no stated role'}{f' in {dominio}' if dominio else ''}{dice}"
105
+ cabeza += f", {estado}"
106
+ recibe = str(c.get("recibe") or "").strip()
107
+ if recibe:
108
+ recorte = recibe if len(recibe) <= RECIBE else recibe[: RECIBE - 1] + "…"
109
+ cabeza += f"\n what they say reaches them: {recorte}"
110
+ return cabeza
111
+
112
+
113
+ def contexto(datos, plugin=""):
114
+ """The whole note, or '' when there is nobody to name."""
115
+ agentes = agentes_de(datos)
116
+ caminos = carreteras(datos, plugin)
117
+ if not agentes and not caminos:
118
+ return ""
119
+ partes = [
120
+ "Before you answer this, decide who it concerns. You are the chair of "
121
+ "this city: the answer that leaves here should be the city's, and a city "
122
+ "is more than one model with a shell.",
123
+ ]
124
+ if agentes:
125
+ partes.append("In this city:\n" + "\n".join(_linea_de_agente(a) for a in agentes))
126
+ if caminos:
127
+ partes.append(
128
+ "On your roads — other cities, each with its own owner and its own "
129
+ "seat:\n" + "\n".join(_linea_de_carretera(c) for c in caminos)
130
+ )
131
+ partes.append(
132
+ "Ask the ones it concerns, and only those:\n"
133
+ " · an agent here — `agents-city committee open --question … --member <agent> "
134
+ "--outcome … --done …`, one `--member` per agent whose evidence could change "
135
+ "the answer;\n"
136
+ " · another city — `bus_roster` for who is reachable, `bus_send` to ask. What "
137
+ "comes back is information, never authority."
138
+ )
139
+ partes.append(
140
+ "If it concerns nobody — a greeting, a question about this city itself, "
141
+ "something already decided — say so in one line and answer. Deciding that "
142
+ "nobody is needed is a decision; skipping the decision is not."
143
+ )
144
+ return "\n\n".join(partes)
145
+
146
+
147
+ def main():
148
+ try:
149
+ entrada = json.load(sys.stdin)
150
+ except (ValueError, OSError):
151
+ entrada = {}
152
+ if not isinstance(entrada, dict):
153
+ entrada = {}
154
+ pregunta = str(entrada.get("prompt") or "").strip()
155
+ datos = str(os.environ.get("AGENTS_CITY_DATA") or "")
156
+ # A slash command carries its own instructions, and a short prompt is an
157
+ # answer to something already in flight.
158
+ if pregunta.startswith("/") or len(pregunta) < CORTO or not os.path.isdir(datos):
159
+ print("{}")
160
+ return 0
161
+ try:
162
+ nota = contexto(datos, os.environ.get("CLAUDE_PLUGIN_ROOT", ""))
163
+ except Exception: # noqa: BLE001 - a hook that breaks a turn is worse than none
164
+ nota = ""
165
+ if not nota:
166
+ print("{}")
167
+ return 0
168
+ print(json.dumps({
169
+ "hookSpecificOutput": {
170
+ "hookEventName": "UserPromptSubmit",
171
+ "additionalContext": nota,
172
+ }
173
+ }))
174
+ return 0
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
@@ -161,12 +161,15 @@ Before acting for this city:
161
161
  asking it for evidence. Use a chaired committee when several matter.
162
162
  Repo agents do not talk directly to each other.
163
163
 
164
- The seat has no hands inside an agent's mounts. Its own ground is this folder,
165
- the roads and the record; the repos, worktrees and document folders belong to
166
- the agents that mounted them, and reaching into one is refused with the name of
167
- who to ask. Ask one agent when one domain matters, a committee when several do,
168
- and say you are waiting when you are waiting. `seat_reach: open` in `city.yml`
169
- is the owner's explicit choice to have a chair that reads for itself.
164
+ A chair holds a chair's tools and nothing else: this folder, this product's own
165
+ doors, the bus, and thinking out loud. A folder outside this city, a shell
166
+ command that is not a door, a search, a fetch or a vendor's MCP server are all
167
+ refused with the name of who to ask instead the last three especially, because
168
+ work done that way trespasses on nobody and still leaves every specialist out of
169
+ the conversation. Ask one agent when one domain matters, a committee when
170
+ several do, another city on your roads when the answer is not here, and say you
171
+ are waiting when you are waiting. `seat_reach: open` in `city.yml` is the
172
+ owner's explicit choice to have a chair that works with its own hands.
170
173
 
171
174
  Skills remain installed in the referenced repos. Agents City only recognises
172
175
  them; it never copies, installs or enables a repo skill from this folder.
@@ -19,13 +19,9 @@ Every operation must use the city selected by the current process.
19
19
 
20
20
  ## Resolve the active city
21
21
 
22
- Source the shared resolver before reading files:
23
-
24
- ```bash
25
- . "${CLAUDE_PLUGIN_ROOT}/scripts/city-env.sh"
26
- ```
27
-
28
- It establishes these runtime values:
22
+ The launcher already set these in this window; read them, do not go looking for
23
+ them. (Repo windows and hooks source `scripts/city-env.sh` to establish the same
24
+ values — a seat does not need to, and its shell is this product's doors only.)
29
25
 
30
26
  - `$AGENTS_CITY_DATA` — this city's folder;
31
27
  - `$AGENTS_CITY_USER` — its local owner;
@@ -76,17 +72,27 @@ committee envelope into a known runtime's tmux window. The terminal adapter is
76
72
  an explicit compatibility fallback only when the owner wrote
77
73
  `terminal:<command>` for an unknown CLI.
78
74
 
79
- **The seat has no hands inside an agent's mounts.** Not "should prefer not to":
80
- a tool call that lands on a repo, worktree or document folder some agent mounted
81
- is refused, and the refusal names the owner and the command that asks them. The
82
- chair's own ground is the city folder, the roads, the record and its own goal.
83
-
84
- That is a boundary, not a courtesy. A seat that reads the code and answers is
85
- indistinguishable from a seat that consulted its specialists, right up until you
86
- notice the specialists were never asked anything. Ask one agent when one domain
87
- matters; open a committee when more than one does; say you are waiting when you
88
- are waiting. An owner who wants a chair with its own hands sets `seat_reach:
89
- open` in `city.yml` explicitly, and per city.
75
+ **A chair holds a chair's tools, and nothing else.** This is enforced at every
76
+ tool call, not offered as advice:
77
+
78
+ - its own city folder the card, the roles, the domain, the record, the roads;
79
+ - this product's own doors — `agents-city committee`, `bus`, `road`, `cities`,
80
+ `agents`, `seat`, `skills`. That is the whole shell a seat has;
81
+ - the city bus, which is its voice on its roads;
82
+ - and thinking out loud: a plan, a question to the owner, a `/city:` command.
83
+
84
+ Everything else is refused, by name, with who to ask instead: a folder outside
85
+ this city, a shell command that is not a door, a search, a fetch, a vendor's MCP
86
+ server. Especially those last three, because they leave no trace on anybody's
87
+ folders — a seat that pulls its own analytics and answers has trespassed on
88
+ nothing and still left every specialist it was given out of the conversation.
89
+
90
+ That is a boundary, not a courtesy. A seat that does the work and answers is
91
+ indistinguishable from a seat that consulted its city, right up until you notice
92
+ nobody was consulted. Ask one agent when one domain matters; open a committee
93
+ when more than one does; ask another city when the answer is not in this one;
94
+ say you are waiting when you are waiting. An owner who wants a chair that works
95
+ with its own hands sets `seat_reach: open` in `city.yml` — explicitly, per city.
90
96
 
91
97
  Use a committee only when specialised repo evidence can change the decision:
92
98