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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +16 -8
- package/README.md +14 -7
- package/bin/test +1 -0
- package/bin/test-alcance.py +168 -26
- package/bin/test-e2e.py +14 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/goals.md +1 -1
- package/plugin/commands/notice.md +1 -1
- package/plugin/commands/session.md +1 -1
- package/plugin/commands/settings.md +1 -1
- package/plugin/commands/team.md +1 -1
- package/plugin/hooks/ask-the-house.sh +12 -7
- package/plugin/hooks/hooks.json +10 -1
- package/plugin/hooks/who-does-this-concern.sh +22 -0
- package/plugin/scripts/alcance.py +173 -47
- package/plugin/scripts/consulta.py +178 -0
- package/plugin/scripts/domains.py +9 -6
- package/plugin/skills/city/SKILL.md +24 -18
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
|
|
1832
|
-
|
|
1833
|
-
una
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
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
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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",
|
package/bin/test-alcance.py
CHANGED
|
@@ -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:
|
|
117
|
-
juzga(datos, "
|
|
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:
|
|
121
|
-
juzga(datos,
|
|
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
|
-
#
|
|
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
|
|
143
|
-
juzga(datos, "Bash", {
|
|
144
|
-
|
|
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
|
|
148
|
-
juzga(datos, "Bash", {
|
|
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":
|
|
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
|
|
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, "
|
|
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"), "
|
|
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
|
|
444
|
-
"ask-the-house.sh" in texto and "
|
|
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, "
|
|
302
|
-
{"
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "city",
|
|
3
3
|
"displayName": "Agents City",
|
|
4
|
-
"version": "0.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",
|
package/plugin/commands/goals.md
CHANGED
|
@@ -4,7 +4,7 @@ argument-hint: ""
|
|
|
4
4
|
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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: "[domain | role | repos | agent-roles | goal | engines | roads |
|
|
|
4
4
|
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
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
|
package/plugin/commands/team.md
CHANGED
|
@@ -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.
|
|
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
|
|
2
|
+
# PreToolUse / every tool — the chair's hands.
|
|
3
3
|
#
|
|
4
|
-
# The seat decides; the
|
|
5
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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"
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
]
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
|
-
"matcher": "
|
|
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
|
|
232
|
-
"""
|
|
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
|
|
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
|
|
258
|
-
"""
|
|
259
|
-
|
|
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
|
|
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
|
|
338
|
+
return True
|
|
265
339
|
if abierto(datos, entorno):
|
|
266
|
-
return
|
|
267
|
-
herramienta
|
|
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
|
|
354
|
+
fuera = [r for r in sitios if not rutas.dentro_de(r, datos)]
|
|
355
|
+
if not fuera:
|
|
270
356
|
return None
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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":
|
|
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
|
-
|
|
165
|
-
the
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
That is
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|