agents-city 0.5.5 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +16 -8
- package/README.md +14 -7
- package/bin/hall.html +3 -0
- package/bin/navegador.mjs +61 -16
- package/bin/test +1 -0
- package/bin/test-alcance.py +168 -26
- package/bin/test-e2e.py +14 -2
- package/bin/test-navegador.py +34 -0
- package/bin/test-seat.py +11 -0
- package/city/web/dist-hall/hall.js +74 -70
- package/city/web/src/bienvenida.ts +9 -1
- package/city/web/src/es.ts +3 -1
- 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/city-session.sh +10 -1
- package/plugin/scripts/consulta.py +190 -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/hall.html
CHANGED
|
@@ -386,6 +386,9 @@ input[type=text]:focus,select:focus{outline:none;border-color:var(--lampara)}
|
|
|
386
386
|
/* ── the guided first run ────────────────────────────────────────────────── */
|
|
387
387
|
.bv{display:flex;flex-direction:column;gap:26px;max-width:min(760px,100%)}
|
|
388
388
|
.bv .prosa{max-width:100%}
|
|
389
|
+
/* The consequence of a choice, under the button that makes it: quieter than the
|
|
390
|
+
question and still legible, because a warning nobody reads is decoration. */
|
|
391
|
+
.bv .prosa.apunte{font-size:13px;margin-top:10px;opacity:.82}
|
|
389
392
|
.bv code.mono{word-break:break-all}
|
|
390
393
|
/* The live rail has nothing to show before a city exists, and it costs half
|
|
391
394
|
the screen exactly when the guide needs it. */
|
package/bin/navegador.mjs
CHANGED
|
@@ -35,11 +35,31 @@ function dondeEstaChrome() {
|
|
|
35
35
|
return CANDIDATOS.find((p) => existsSync(p)) || '';
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** Chrome prints its DevTools endpoint on stderr; that is the handshake.
|
|
39
|
-
|
|
38
|
+
/** Chrome prints its DevTools endpoint on stderr; that is the handshake.
|
|
39
|
+
*
|
|
40
|
+
* Sixty seconds, not twenty. This is a browser cold-starting on a shared CI
|
|
41
|
+
* runner that is also finishing two thousand other checks, and twenty seconds
|
|
42
|
+
* was a coin toss we kept winning until we did not. Waiting longer costs
|
|
43
|
+
* nothing when it works, because the wait ends at the handshake.
|
|
44
|
+
*
|
|
45
|
+
* And a timeout now carries whatever Chrome DID say. "chrome never announced
|
|
46
|
+
* its port" is a sentence about the observer: it names what we stopped waiting
|
|
47
|
+
* for and drops the one piece of evidence — a missing library, a sandbox
|
|
48
|
+
* refusal, a profile it could not write — that says which of those it was.
|
|
49
|
+
*/
|
|
50
|
+
function esperaEndpoint(proceso, limite = Number(process.env.CITY_BROWSER_WAIT_MS) || 60000) {
|
|
40
51
|
return new Promise((listo, falla) => {
|
|
41
52
|
let visto = '';
|
|
42
|
-
const reloj = setTimeout(
|
|
53
|
+
const reloj = setTimeout(
|
|
54
|
+
() =>
|
|
55
|
+
falla(
|
|
56
|
+
new Error(
|
|
57
|
+
`chrome never announced its port in ${limite / 1000}s` +
|
|
58
|
+
(visto.trim() ? `; it said: ${visto.trim().slice(-400)}` : ', and said nothing'),
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
limite,
|
|
62
|
+
);
|
|
43
63
|
proceso.stderr.on('data', (trozo) => {
|
|
44
64
|
visto += trozo.toString();
|
|
45
65
|
const m = visto.match(/ws:\/\/[^\s]+/);
|
|
@@ -65,7 +85,9 @@ class Cdp {
|
|
|
65
85
|
const espera = this.pendientes.get(mensaje.id);
|
|
66
86
|
if (!espera) return;
|
|
67
87
|
this.pendientes.delete(mensaje.id);
|
|
68
|
-
mensaje.error
|
|
88
|
+
mensaje.error
|
|
89
|
+
? espera.falla(new Error(JSON.stringify(mensaje.error)))
|
|
90
|
+
: espera.listo(mensaje.result);
|
|
69
91
|
});
|
|
70
92
|
}
|
|
71
93
|
|
|
@@ -73,7 +95,9 @@ class Cdp {
|
|
|
73
95
|
const socket = new WebSocket(url);
|
|
74
96
|
await new Promise((listo, falla) => {
|
|
75
97
|
socket.addEventListener('open', listo, { once: true });
|
|
76
|
-
socket.addEventListener('error', () => falla(new Error('cannot reach chrome')), {
|
|
98
|
+
socket.addEventListener('error', () => falla(new Error('cannot reach chrome')), {
|
|
99
|
+
once: true,
|
|
100
|
+
});
|
|
77
101
|
});
|
|
78
102
|
return new Cdp(socket);
|
|
79
103
|
}
|
|
@@ -102,7 +126,9 @@ const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
102
126
|
|
|
103
127
|
let fallos = 0;
|
|
104
128
|
function comprueba(texto, bien, detalle = '') {
|
|
105
|
-
console.log(
|
|
129
|
+
console.log(
|
|
130
|
+
`${bien ? ' ok ·' : ' FAIL·'} ${texto}${bien || !detalle ? '' : `\n ${detalle}`}`,
|
|
131
|
+
);
|
|
106
132
|
if (!bien) fallos += 1;
|
|
107
133
|
}
|
|
108
134
|
|
|
@@ -195,7 +221,11 @@ async function main() {
|
|
|
195
221
|
test: boton('.rpgTest'),
|
|
196
222
|
};
|
|
197
223
|
})()`);
|
|
198
|
-
comprueba(
|
|
224
|
+
comprueba(
|
|
225
|
+
'the houses view renders its sheets',
|
|
226
|
+
(abierto?.fichas ?? 0) > 0,
|
|
227
|
+
JSON.stringify(abierto),
|
|
228
|
+
);
|
|
199
229
|
for (const [control, etiqueta] of [
|
|
200
230
|
['alta', 'the build-a-house button'],
|
|
201
231
|
['instrucciones', 'the CLAUDE.md / AGENTS.md editors'],
|
|
@@ -316,8 +346,11 @@ async function main() {
|
|
|
316
346
|
})()`);
|
|
317
347
|
comprueba(
|
|
318
348
|
'the house form walks the disk — nothing offered, you pick what you want',
|
|
319
|
-
(casa?.filas ?? 0) > 0 &&
|
|
320
|
-
casa
|
|
349
|
+
(casa?.filas ?? 0) > 0 &&
|
|
350
|
+
!!casa?.sube &&
|
|
351
|
+
!!casa?.tomaAqui &&
|
|
352
|
+
casa.dondeDespues !== casa.dondeAntes &&
|
|
353
|
+
(casa?.elegidas ?? 0) > 0,
|
|
321
354
|
JSON.stringify(casa),
|
|
322
355
|
);
|
|
323
356
|
comprueba(
|
|
@@ -468,13 +501,16 @@ async function main() {
|
|
|
468
501
|
})()`);
|
|
469
502
|
comprueba(
|
|
470
503
|
'thirty connected people use one recipient picker, not thirty message boxes',
|
|
471
|
-
reception?.navRole === 'button' &&
|
|
472
|
-
reception?.
|
|
504
|
+
reception?.navRole === 'button' &&
|
|
505
|
+
reception?.navTabIndex === '0' &&
|
|
506
|
+
reception?.composers === 1 &&
|
|
507
|
+
reception?.recipients === 30,
|
|
473
508
|
JSON.stringify(reception),
|
|
474
509
|
);
|
|
475
510
|
comprueba(
|
|
476
511
|
'pending messages stay above composing and use distinct route/reject language',
|
|
477
|
-
reception?.messages === 1 &&
|
|
512
|
+
reception?.messages === 1 &&
|
|
513
|
+
reception?.reviewFirst === true &&
|
|
478
514
|
/route|dirigir/i.test(reception?.route ?? '') &&
|
|
479
515
|
/reason|motivo/i.test(reception?.rejection ?? ''),
|
|
480
516
|
JSON.stringify(reception),
|
|
@@ -687,8 +723,11 @@ async function main() {
|
|
|
687
723
|
})()`);
|
|
688
724
|
comprueba(
|
|
689
725
|
'the language switch really translates the page, and remembers it',
|
|
690
|
-
!!lengua &&
|
|
691
|
-
|
|
726
|
+
!!lengua &&
|
|
727
|
+
!lengua.falta &&
|
|
728
|
+
lengua.despues !== lengua.antes &&
|
|
729
|
+
/casas|resumen|mapa/i.test(lengua.despues) &&
|
|
730
|
+
lengua.guardado === lengua.lang &&
|
|
692
731
|
lengua.etiqueta !== lengua.etiquetaAntes,
|
|
693
732
|
JSON.stringify(lengua),
|
|
694
733
|
);
|
|
@@ -698,8 +737,14 @@ async function main() {
|
|
|
698
737
|
JSON.stringify(lengua?.marca),
|
|
699
738
|
);
|
|
700
739
|
|
|
701
|
-
const errores = await cdp.evalua(
|
|
702
|
-
|
|
740
|
+
const errores = await cdp.evalua(
|
|
741
|
+
'window.__erroresDePagina ? window.__erroresDePagina.length : 0',
|
|
742
|
+
);
|
|
743
|
+
comprueba(
|
|
744
|
+
'the page raised no uncaught errors while we drove it',
|
|
745
|
+
errores === 0,
|
|
746
|
+
String(errores),
|
|
747
|
+
);
|
|
703
748
|
} finally {
|
|
704
749
|
proceso.kill('SIGKILL');
|
|
705
750
|
}
|
package/bin/test
CHANGED
|
@@ -92,6 +92,7 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
|
|
|
92
92
|
"plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
|
|
93
93
|
"plugin/scripts/demos.py", "plugin/scripts/arnes.py",
|
|
94
94
|
"plugin/scripts/diario.py", "plugin/scripts/alcance.py",
|
|
95
|
+
"plugin/scripts/consulta.py",
|
|
95
96
|
"plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
|
|
96
97
|
"plugin/scripts/deliberations.py",
|
|
97
98
|
"plugin/scripts/runtime_processes.py",
|
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/bin/test-navegador.py
CHANGED
|
@@ -145,9 +145,43 @@ def bundle_al_dia():
|
|
|
145
145
|
return "rebuilt from src/hall.ts"
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def un_chrome_que_no_arranca():
|
|
149
|
+
"""What the driver says when the browser never answers.
|
|
150
|
+
|
|
151
|
+
This suite has exactly one way to fail without telling anybody anything, and
|
|
152
|
+
CI found it: `chrome never announced its port` names what we stopped waiting
|
|
153
|
+
for and drops the only evidence of why — a missing library, a sandbox
|
|
154
|
+
refusal, a profile it could not write. A stub browser that talks and never
|
|
155
|
+
announces proves the message carries what it heard.
|
|
156
|
+
|
|
157
|
+
CHROME_PATH is the first candidate the driver considers, which is what makes
|
|
158
|
+
this testable without a browser at all.
|
|
159
|
+
"""
|
|
160
|
+
print(" and a browser that never starts says why")
|
|
161
|
+
casa = tempfile.mkdtemp()
|
|
162
|
+
falso = os.path.join(casa, "chrome")
|
|
163
|
+
with open(falso, "w", encoding="utf-8") as f:
|
|
164
|
+
f.write("#!/bin/bash\n"
|
|
165
|
+
"echo 'FATAL: could not open display, and this is the useful half' >&2\n"
|
|
166
|
+
"sleep 30\n")
|
|
167
|
+
os.chmod(falso, 0o755)
|
|
168
|
+
salida = subprocess.run(
|
|
169
|
+
["node", os.path.join(RAIZ, "bin", "navegador.mjs"), "http://127.0.0.1:1/"],
|
|
170
|
+
capture_output=True, text=True, timeout=90,
|
|
171
|
+
env=dict(os.environ, CHROME_PATH=falso, CITY_BROWSER_WAIT_MS="1500"),
|
|
172
|
+
)
|
|
173
|
+
shutil.rmtree(casa, ignore_errors=True)
|
|
174
|
+
todo = salida.stdout + salida.stderr
|
|
175
|
+
afirma("· the timeout names what it waited for",
|
|
176
|
+
"never announced its port" in todo, todo[-300:])
|
|
177
|
+
afirma("· and repeats what the browser actually said",
|
|
178
|
+
"could not open display" in todo, todo[-300:])
|
|
179
|
+
|
|
180
|
+
|
|
148
181
|
def main():
|
|
149
182
|
print()
|
|
150
183
|
print(" the hall, in a browser that clicks")
|
|
184
|
+
un_chrome_que_no_arranca()
|
|
151
185
|
print(f" · {bundle_al_dia()}")
|
|
152
186
|
datos = tempfile.mkdtemp()
|
|
153
187
|
casa = tempfile.mkdtemp()
|
package/bin/test-seat.py
CHANGED
|
@@ -1521,6 +1521,11 @@ def arranque_escalonado():
|
|
|
1521
1521
|
corre(**extra)
|
|
1522
1522
|
return open(registro).read()
|
|
1523
1523
|
|
|
1524
|
+
primera = crudo(CITY_SETTLE="0", CITY_STAGGER="0")
|
|
1525
|
+
afirma(
|
|
1526
|
+
"· happy: a city opened for the first time gets its tmux comforts",
|
|
1527
|
+
"set-option" in primera, primera[:300],
|
|
1528
|
+
)
|
|
1524
1529
|
log = crudo(FAKE_SESSION="1", FAKE_WINDOWS="seat api", CITY_SETTLE="0", CITY_STAGGER="0")
|
|
1525
1530
|
afirma(
|
|
1526
1531
|
"· happy: an agent added to a running city gets its window",
|
|
@@ -1542,6 +1547,12 @@ def arranque_escalonado():
|
|
|
1542
1547
|
"new-session" not in log,
|
|
1543
1548
|
log[:600],
|
|
1544
1549
|
)
|
|
1550
|
+
afirma(
|
|
1551
|
+
"· non-happy: and a running session's tmux options are left exactly as found",
|
|
1552
|
+
"set-option" not in log,
|
|
1553
|
+
"re-applying `set -g mouse on` under a live full-screen app types raw SGR "
|
|
1554
|
+
"sequences into its prompt: " + log[:400],
|
|
1555
|
+
)
|
|
1545
1556
|
afirma(
|
|
1546
1557
|
"· the new house is what you are looking at when it opens",
|
|
1547
1558
|
"select-window" in log and ":docs" in log.split("select-window")[-1][:40],
|