agents-city 0.1.0
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 +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2226 -0
- package/README.md +2196 -0
- package/benchmarks/committee/README.md +39 -0
- package/benchmarks/committee/metrics.py +51 -0
- package/benchmarks/committee/run.py +64 -0
- package/benchmarks/committee/traces/01-single.json +8 -0
- package/benchmarks/committee/traces/02-mesh.json +16 -0
- package/benchmarks/committee/traces/03-chair.json +15 -0
- package/benchmarks/latency/README.md +35 -0
- package/benchmarks/latency/fake-claude-cli.mjs +119 -0
- package/benchmarks/latency/fake-native-server.mjs +340 -0
- package/benchmarks/latency/fake-tui.py +91 -0
- package/benchmarks/latency/live.py +380 -0
- package/benchmarks/stress/README.md +20 -0
- package/benchmarks/stress/run.py +487 -0
- package/bin/agents +4 -0
- package/bin/agents-city.js +119 -0
- package/bin/benchmark +17 -0
- package/bin/bus +6 -0
- package/bin/cities +3 -0
- package/bin/city +86 -0
- package/bin/committee +6 -0
- package/bin/demo +200 -0
- package/bin/doctor +3 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +498 -0
- package/bin/logs +3 -0
- package/bin/navegador.mjs +243 -0
- package/bin/report.py +14 -0
- package/bin/reset +3 -0
- package/bin/road +3 -0
- package/bin/seat +10 -0
- package/bin/serve.py +1579 -0
- package/bin/setup.py +753 -0
- package/bin/shortcut +3 -0
- package/bin/skills +3 -0
- package/bin/test +133 -0
- package/bin/test-actualiza.py +130 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-atajos.py +301 -0
- package/bin/test-avatar.py +58 -0
- package/bin/test-benchmark.py +40 -0
- package/bin/test-broker.py +334 -0
- package/bin/test-cage.py +374 -0
- package/bin/test-card.py +402 -0
- package/bin/test-channel.py +379 -0
- package/bin/test-cities.py +453 -0
- package/bin/test-claude-runtime.py +252 -0
- package/bin/test-committee.py +340 -0
- package/bin/test-contracts.py +551 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +139 -0
- package/bin/test-domains.py +151 -0
- package/bin/test-evidencia.py +50 -0
- package/bin/test-exit.py +128 -0
- package/bin/test-hall-protocol.py +93 -0
- package/bin/test-launch.py +169 -0
- package/bin/test-live-feed.py +508 -0
- package/bin/test-navegador.py +140 -0
- package/bin/test-pairing.py +99 -0
- package/bin/test-parcels.py +283 -0
- package/bin/test-runtime-failures.py +252 -0
- package/bin/test-runtime-ui.py +488 -0
- package/bin/test-runtime.py +268 -0
- package/bin/test-rutas.py +80 -0
- package/bin/test-seat.py +1598 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1251 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +251 -0
- package/bin/testlib.py +118 -0
- package/bin/tokens.py +14 -0
- package/bin/update +3 -0
- package/bus/scripts/deploy.sh +60 -0
- package/bus/scripts/mint-token.sh +68 -0
- package/bus/scripts/setup-dev.sh +79 -0
- package/bus/scripts/test-channel.ts +8 -0
- package/bus/scripts/test-hub.ts +266 -0
- package/bus/scripts/test-local.sh +40 -0
- package/bus/scripts/test-queue.ts +132 -0
- package/bus/worker/package-lock.json +1971 -0
- package/bus/worker/package.json +17 -0
- package/bus/worker/src/index.ts +485 -0
- package/bus/worker/tsconfig.json +13 -0
- package/bus/worker/wrangler.toml +32 -0
- package/city/oven/README.md +59 -0
- package/city/oven/collect.py +43 -0
- package/city/oven/oven.html +4 -0
- package/city/scripts/history.py +120 -0
- package/city/scripts/seed.py +442 -0
- package/city/web/assets/modelos/License.txt +28 -0
- package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/assets/modelos/building-a.glb +0 -0
- package/city/web/assets/modelos/building-b.glb +0 -0
- package/city/web/assets/modelos/building-c.glb +0 -0
- package/city/web/assets/modelos/building-d.glb +0 -0
- package/city/web/assets/modelos/building-e.glb +0 -0
- package/city/web/assets/modelos/building-f.glb +0 -0
- package/city/web/assets/modelos/building-g.glb +0 -0
- package/city/web/assets/modelos/building-h.glb +0 -0
- package/city/web/assets/sprites/building-a.png +0 -0
- package/city/web/assets/sprites/building-b.png +0 -0
- package/city/web/assets/sprites/building-c.png +0 -0
- package/city/web/assets/sprites/building-d.png +0 -0
- package/city/web/assets/sprites/building-e.png +0 -0
- package/city/web/assets/sprites/building-f.png +0 -0
- package/city/web/assets/sprites/building-g.png +0 -0
- package/city/web/assets/sprites/building-h.png +0 -0
- package/city/web/assets/sprites/building-i.png +0 -0
- package/city/web/assets/sprites/building-j.png +0 -0
- package/city/web/assets/sprites/building-k.png +0 -0
- package/city/web/assets/sprites/building-l.png +0 -0
- package/city/web/assets/sprites/building-m.png +0 -0
- package/city/web/assets/sprites/building-n.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
- package/city/web/assets/sprites/catalogo.json +47 -0
- package/city/web/assets/sprites/detail-awning-wide.png +0 -0
- package/city/web/assets/sprites/detail-awning.png +0 -0
- package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
- package/city/web/assets/sprites/detail-overhang.png +0 -0
- package/city/web/assets/sprites/detail-parasol-a.png +0 -0
- package/city/web/assets/sprites/detail-parasol-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-c.png +0 -0
- package/city/web/assets/sprites/low-detail-building-d.png +0 -0
- package/city/web/assets/sprites/low-detail-building-e.png +0 -0
- package/city/web/assets/sprites/low-detail-building-f.png +0 -0
- package/city/web/assets/sprites/low-detail-building-g.png +0 -0
- package/city/web/assets/sprites/low-detail-building-h.png +0 -0
- package/city/web/assets/sprites/low-detail-building-i.png +0 -0
- package/city/web/assets/sprites/low-detail-building-j.png +0 -0
- package/city/web/assets/sprites/low-detail-building-k.png +0 -0
- package/city/web/assets/sprites/low-detail-building-l.png +0 -0
- package/city/web/assets/sprites/low-detail-building-m.png +0 -0
- package/city/web/assets/sprites/low-detail-building-n.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/assets/sprites/medidas.json +248 -0
- package/city/web/dist/city.js +939 -0
- package/city/web/dist/index.html +294 -0
- package/city/web/dist/modelos/License.txt +28 -0
- package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/dist/modelos/building-a.glb +0 -0
- package/city/web/dist/modelos/building-b.glb +0 -0
- package/city/web/dist/modelos/building-c.glb +0 -0
- package/city/web/dist/modelos/building-d.glb +0 -0
- package/city/web/dist/modelos/building-e.glb +0 -0
- package/city/web/dist/modelos/building-f.glb +0 -0
- package/city/web/dist/modelos/building-g.glb +0 -0
- package/city/web/dist/modelos/building-h.glb +0 -0
- package/city/web/dist/sprites/building-a.png +0 -0
- package/city/web/dist/sprites/building-b.png +0 -0
- package/city/web/dist/sprites/building-c.png +0 -0
- package/city/web/dist/sprites/building-d.png +0 -0
- package/city/web/dist/sprites/building-e.png +0 -0
- package/city/web/dist/sprites/building-f.png +0 -0
- package/city/web/dist/sprites/building-g.png +0 -0
- package/city/web/dist/sprites/building-h.png +0 -0
- package/city/web/dist/sprites/building-i.png +0 -0
- package/city/web/dist/sprites/building-j.png +0 -0
- package/city/web/dist/sprites/building-k.png +0 -0
- package/city/web/dist/sprites/building-l.png +0 -0
- package/city/web/dist/sprites/building-m.png +0 -0
- package/city/web/dist/sprites/building-n.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
- package/city/web/dist/sprites/catalogo.json +47 -0
- package/city/web/dist/sprites/detail-awning-wide.png +0 -0
- package/city/web/dist/sprites/detail-awning.png +0 -0
- package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
- package/city/web/dist/sprites/detail-overhang.png +0 -0
- package/city/web/dist/sprites/detail-parasol-a.png +0 -0
- package/city/web/dist/sprites/detail-parasol-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-c.png +0 -0
- package/city/web/dist/sprites/low-detail-building-d.png +0 -0
- package/city/web/dist/sprites/low-detail-building-e.png +0 -0
- package/city/web/dist/sprites/low-detail-building-f.png +0 -0
- package/city/web/dist/sprites/low-detail-building-g.png +0 -0
- package/city/web/dist/sprites/low-detail-building-h.png +0 -0
- package/city/web/dist/sprites/low-detail-building-i.png +0 -0
- package/city/web/dist/sprites/low-detail-building-j.png +0 -0
- package/city/web/dist/sprites/low-detail-building-k.png +0 -0
- package/city/web/dist/sprites/low-detail-building-l.png +0 -0
- package/city/web/dist/sprites/low-detail-building-m.png +0 -0
- package/city/web/dist/sprites/low-detail-building-n.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/dist/sprites/medidas.json +248 -0
- package/city/web/dist-hall/hall.js +1363 -0
- package/city/web/index.html +294 -0
- package/city/web/package.json +18 -0
- package/city/web/sella.py +21 -0
- package/city/web/src/activity-actors.ts +47 -0
- package/city/web/src/activity.ts +172 -0
- package/city/web/src/ayuntamiento.ts +580 -0
- package/city/web/src/draw.ts +640 -0
- package/city/web/src/game-speech.ts +79 -0
- package/city/web/src/hall.ts +1959 -0
- package/city/web/src/main.ts +2252 -0
- package/city/web/src/oven.ts +124 -0
- package/city/web/src/people.ts +244 -0
- package/city/web/src/presencia.ts +193 -0
- package/city/web/src/puertas.ts +180 -0
- package/city/web/src/repo-roles.ts +59 -0
- package/city/worker/package.json +16 -0
- package/city/worker/schema.sql +117 -0
- package/city/worker/src/index.ts +702 -0
- package/city/worker/src/square.ts +218 -0
- package/city/worker/wrangler.toml +58 -0
- package/demo/ada.md +50 -0
- package/demo/bruno.md +62 -0
- package/demo/camila.md +56 -0
- package/demo/city/ada.md +40 -0
- package/demo/city/city.yml +8 -0
- package/demo/city/parcels.yml +15 -0
- package/demo/city/roads.json +4 -0
- package/demo/city/units.yml +10 -0
- package/demo/clinica/city.yml +8 -0
- package/demo/clinica/parcels.yml +15 -0
- package/demo/clinica/roads.json +4 -0
- package/demo/clinica/units.yml +9 -0
- package/demo/clinica/vera.md +46 -0
- package/demo/dante.md +54 -0
- package/demo/despacho/city.yml +8 -0
- package/demo/despacho/marta.md +46 -0
- package/demo/despacho/parcels.yml +15 -0
- package/demo/despacho/roads.json +4 -0
- package/demo/despacho/units.yml +9 -0
- package/demo/elsa.md +50 -0
- package/demo/farid.md +52 -0
- package/demo/greta.md +50 -0
- package/demo/hugo.md +49 -0
- package/demo/iris.md +49 -0
- package/demo/jonas.md +51 -0
- package/demo/kira.md +51 -0
- package/demo/luca.md +49 -0
- package/demo/parcels.yml +92 -0
- package/demo/seed.py +20 -0
- package/demo/show.py +161 -0
- package/demo/stories.py +824 -0
- package/demo/units.yml +33 -0
- package/docs/agents-first.md +106 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +265 -0
- package/docs/self-host.md +151 -0
- package/docs/testing.md +205 -0
- package/package.json +73 -0
- package/plugin/.claude-plugin/plugin.json +62 -0
- package/plugin/.mcp.json +15 -0
- package/plugin/channel/activity-cli.ts +16 -0
- package/plugin/channel/adapter-prompts.ts +113 -0
- package/plugin/channel/adapter.js +4292 -0
- package/plugin/channel/adapter.ts +133 -0
- package/plugin/channel/bus.js +20188 -0
- package/plugin/channel/bus.ts +131 -0
- package/plugin/channel/city-config.ts +125 -0
- package/plugin/channel/claude-channel.ts +58 -0
- package/plugin/channel/cli-args.ts +75 -0
- package/plugin/channel/client.js +4200 -0
- package/plugin/channel/client.ts +38 -0
- package/plugin/channel/committee/activity.ts +256 -0
- package/plugin/channel/committee/collection.ts +154 -0
- package/plugin/channel/committee/decision.ts +175 -0
- package/plugin/channel/committee/floor.ts +135 -0
- package/plugin/channel/committee/guards.ts +30 -0
- package/plugin/channel/committee/history.ts +38 -0
- package/plugin/channel/committee/render.ts +102 -0
- package/plugin/channel/committee/service.ts +72 -0
- package/plugin/channel/committee/storage.ts +92 -0
- package/plugin/channel/committee/types.ts +163 -0
- package/plugin/channel/committee/view.ts +63 -0
- package/plugin/channel/committee-cli.ts +109 -0
- package/plugin/channel/delivery-metrics.ts +61 -0
- package/plugin/channel/delivery-queue.ts +118 -0
- package/plugin/channel/hub/activity-controller.ts +72 -0
- package/plugin/channel/hub/activity-feed.ts +169 -0
- package/plugin/channel/hub/committee-controller.ts +60 -0
- package/plugin/channel/hub/connections.ts +48 -0
- package/plugin/channel/hub/diagnostics.ts +67 -0
- package/plugin/channel/hub/envelope-validity.ts +109 -0
- package/plugin/channel/hub/envelopes.ts +66 -0
- package/plugin/channel/hub/lifecycle.ts +91 -0
- package/plugin/channel/hub/local-roads.ts +89 -0
- package/plugin/channel/hub/remote-roads.ts +165 -0
- package/plugin/channel/hub/road-controller.ts +99 -0
- package/plugin/channel/hub-client.ts +149 -0
- package/plugin/channel/local-hub.js +5995 -0
- package/plugin/channel/local-hub.ts +349 -0
- package/plugin/channel/map-reporter.ts +48 -0
- package/plugin/channel/package-lock.json +1731 -0
- package/plugin/channel/package.json +19 -0
- package/plugin/channel/protocol.ts +98 -0
- package/plugin/channel/road-cli.ts +18 -0
- package/plugin/channel/run.sh +15 -0
- package/plugin/channel/runtime/claude.ts +458 -0
- package/plugin/channel/runtime/codex-config.ts +77 -0
- package/plugin/channel/runtime/codex.ts +669 -0
- package/plugin/channel/runtime/command.ts +62 -0
- package/plugin/channel/runtime/factory.ts +15 -0
- package/plugin/channel/runtime/json-rpc.ts +154 -0
- package/plugin/channel/runtime/kimi.ts +277 -0
- package/plugin/channel/runtime/opencode.ts +248 -0
- package/plugin/channel/runtime/process.ts +84 -0
- package/plugin/channel/runtime/types.ts +45 -0
- package/plugin/channel/runtime-files.ts +73 -0
- package/plugin/channel/runtime-gateway.js +6396 -0
- package/plugin/channel/runtime-gateway.ts +358 -0
- package/plugin/channel/runtime-metrics.ts +52 -0
- package/plugin/channel/runtime-subscription.ts +180 -0
- package/plugin/channel/terminal-delivery.ts +131 -0
- package/plugin/channel/untrusted.ts +60 -0
- package/plugin/commands/committee.md +31 -0
- package/plugin/commands/exit.md +32 -0
- package/plugin/commands/goals.md +20 -0
- package/plugin/commands/join.md +18 -0
- package/plugin/commands/notice.md +20 -0
- package/plugin/commands/propose.md +19 -0
- package/plugin/commands/round.md +17 -0
- package/plugin/commands/session.md +22 -0
- package/plugin/commands/settings.md +25 -0
- package/plugin/commands/setup.md +21 -0
- package/plugin/commands/team.md +23 -0
- package/plugin/domains/custom.md +27 -0
- package/plugin/domains/finance.md +31 -0
- package/plugin/domains/healthcare.md +34 -0
- package/plugin/domains/legal.md +31 -0
- package/plugin/domains/marketing.md +33 -0
- package/plugin/domains/operations.md +31 -0
- package/plugin/domains/research.md +32 -0
- package/plugin/domains/sales.md +30 -0
- package/plugin/domains/software.md +37 -0
- package/plugin/hooks/activity.sh +6 -0
- package/plugin/hooks/digging.sh +68 -0
- package/plugin/hooks/growth.sh +42 -0
- package/plugin/hooks/hooks.json +106 -0
- package/plugin/hooks/notice-on-pr.sh +35 -0
- package/plugin/hooks/notice-on-stop.sh +80 -0
- package/plugin/hooks/notice-pending.sh +58 -0
- package/plugin/hooks/solo-en-ciudad.sh +23 -0
- package/plugin/hooks/tokens.sh +47 -0
- package/plugin/roles/examples/account-executive.md +25 -0
- package/plugin/roles/examples/ai-manager.md +34 -0
- package/plugin/roles/examples/associate.md +24 -0
- package/plugin/roles/examples/brand-lead.md +20 -0
- package/plugin/roles/examples/cfo.md +20 -0
- package/plugin/roles/examples/city-lead.md +25 -0
- package/plugin/roles/examples/clinical-director.md +26 -0
- package/plugin/roles/examples/clinical-ops.md +25 -0
- package/plugin/roles/examples/clinician.md +25 -0
- package/plugin/roles/examples/compliance.md +27 -0
- package/plugin/roles/examples/content.md +24 -0
- package/plugin/roles/examples/controller.md +27 -0
- package/plugin/roles/examples/cpto.md +41 -0
- package/plugin/roles/examples/customer-success.md +25 -0
- package/plugin/roles/examples/data-engineer.md +37 -0
- package/plugin/roles/examples/data.md +60 -0
- package/plugin/roles/examples/dev.md +37 -0
- package/plugin/roles/examples/devops.md +39 -0
- package/plugin/roles/examples/enablement.md +24 -0
- package/plugin/roles/examples/ethics.md +24 -0
- package/plugin/roles/examples/fin-analytics.md +27 -0
- package/plugin/roles/examples/health-compliance.md +25 -0
- package/plugin/roles/examples/health-data.md +26 -0
- package/plugin/roles/examples/knowledge.md +25 -0
- package/plugin/roles/examples/lifecycle.md +26 -0
- package/plugin/roles/examples/llm-engineer.md +34 -0
- package/plugin/roles/examples/managing-partner.md +20 -0
- package/plugin/roles/examples/methods.md +23 -0
- package/plugin/roles/examples/operations-lead.md +23 -0
- package/plugin/roles/examples/ops.md +24 -0
- package/plugin/roles/examples/patient-safety.md +26 -0
- package/plugin/roles/examples/performance.md +27 -0
- package/plugin/roles/examples/po.md +36 -0
- package/plugin/roles/examples/process-owner.md +23 -0
- package/plugin/roles/examples/product-design.md +39 -0
- package/plugin/roles/examples/program-manager.md +24 -0
- package/plugin/roles/examples/quality.md +25 -0
- package/plugin/roles/examples/research-director.md +24 -0
- package/plugin/roles/examples/research-ops.md +25 -0
- package/plugin/roles/examples/researcher.md +25 -0
- package/plugin/roles/examples/revenue-lead.md +25 -0
- package/plugin/roles/examples/revops.md +26 -0
- package/plugin/roles/examples/seo.md +27 -0
- package/plugin/roles/examples/specialist.md +24 -0
- package/plugin/scripts/actualiza.py +198 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +492 -0
- package/plugin/scripts/capabilities.py +210 -0
- package/plugin/scripts/card.py +322 -0
- package/plugin/scripts/cities.py +520 -0
- package/plugin/scripts/city-env.sh +90 -0
- package/plugin/scripts/city-runtime.sh +87 -0
- package/plugin/scripts/city-session.sh +552 -0
- package/plugin/scripts/city_env.py +67 -0
- package/plugin/scripts/crecimiento.py +140 -0
- package/plugin/scripts/deliberations.py +48 -0
- package/plugin/scripts/doctor.py +294 -0
- package/plugin/scripts/domains.py +201 -0
- package/plugin/scripts/evidencia.py +64 -0
- package/plugin/scripts/find-repos.sh +111 -0
- package/plugin/scripts/gh.py +159 -0
- package/plugin/scripts/hall_protocol.py +147 -0
- package/plugin/scripts/hook_activity.py +139 -0
- package/plugin/scripts/launch.py +94 -0
- package/plugin/scripts/logs.py +98 -0
- package/plugin/scripts/pairing.py +185 -0
- package/plugin/scripts/parcels.py +132 -0
- package/plugin/scripts/read-card.py +55 -0
- package/plugin/scripts/report.py +201 -0
- package/plugin/scripts/reset.py +180 -0
- package/plugin/scripts/roads.py +230 -0
- package/plugin/scripts/roles.py +274 -0
- package/plugin/scripts/runtime_log.py +69 -0
- package/plugin/scripts/runtime_processes.py +132 -0
- package/plugin/scripts/rutas.py +83 -0
- package/plugin/scripts/seat.py +1696 -0
- package/plugin/scripts/tokens.py +194 -0
- package/plugin/scripts/trust-repos.py +59 -0
- package/plugin/scripts/ui.py +280 -0
- package/plugin/scripts/units.py +80 -0
- package/plugin/scripts/workspace.py +561 -0
- package/plugin/skills/city/SKILL.md +191 -0
- package/templates/blank.md +39 -0
- package/templates/finance.md +44 -0
- package/templates/legal.md +45 -0
- package/templates/marketing.md +48 -0
- package/templates/product.md +45 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Browser spectator E2E: happy committee, moderated replies and hard failures."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.parse
|
|
13
|
+
|
|
14
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
15
|
+
RAIZ = os.path.dirname(AQUI)
|
|
16
|
+
sys.path.insert(0, AQUI)
|
|
17
|
+
from testlib import afirma, detiene_hubs_de_ciudad, resumen # noqa: E402
|
|
18
|
+
|
|
19
|
+
CLIENT = os.path.join(RAIZ, 'plugin', 'channel', 'client.js')
|
|
20
|
+
CLAUDE_ACTIVITY = os.path.join(RAIZ, 'plugin', 'hooks', 'activity.sh')
|
|
21
|
+
CHANNEL_DIR = os.path.join(RAIZ, 'plugin', 'channel')
|
|
22
|
+
WATCH_JS = r"""
|
|
23
|
+
const WebSocket = require('ws');
|
|
24
|
+
const readline = require('readline');
|
|
25
|
+
const ws = new WebSocket(process.argv[1], { origin: process.argv[2] });
|
|
26
|
+
ws.on('open', () => process.stdout.write(JSON.stringify({type:'test.open'}) + '\n'));
|
|
27
|
+
ws.on('message', raw => process.stdout.write(String(raw) + '\n'));
|
|
28
|
+
ws.on('unexpected-response', (_request, response) => {
|
|
29
|
+
process.stdout.write(JSON.stringify({type:'test.rejected', status:response.statusCode}) + '\n');
|
|
30
|
+
process.exit(3);
|
|
31
|
+
});
|
|
32
|
+
ws.on('error', error => process.stderr.write(error.message + '\n'));
|
|
33
|
+
readline.createInterface({input:process.stdin}).on('line', line => {
|
|
34
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(line);
|
|
35
|
+
});
|
|
36
|
+
process.on('SIGTERM', () => { try { ws.close(); } catch {} process.exit(0); });
|
|
37
|
+
"""
|
|
38
|
+
REJECT_JS = r"""
|
|
39
|
+
const WebSocket = require('ws');
|
|
40
|
+
const ws = new WebSocket(process.argv[1], { origin: process.argv[2] });
|
|
41
|
+
let done = false;
|
|
42
|
+
const finish = code => {
|
|
43
|
+
if (done) return;
|
|
44
|
+
done = true;
|
|
45
|
+
try { ws.close(); } catch {}
|
|
46
|
+
process.exit(code);
|
|
47
|
+
};
|
|
48
|
+
ws.on('open', () => finish(2));
|
|
49
|
+
ws.on('unexpected-response', (_request, response) => finish(response.statusCode === 403 ? 0 : 3));
|
|
50
|
+
ws.on('error', () => {});
|
|
51
|
+
setTimeout(() => finish(4), 4000);
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Watcher:
|
|
56
|
+
def __init__(self, url):
|
|
57
|
+
self.messages = []
|
|
58
|
+
self.lines = queue.Queue()
|
|
59
|
+
self.errors = []
|
|
60
|
+
self.process = subprocess.Popen(
|
|
61
|
+
['node', '--input-type=commonjs', '-e', WATCH_JS, url,
|
|
62
|
+
'http://127.0.0.1:43210'],
|
|
63
|
+
cwd=CHANNEL_DIR, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
64
|
+
stderr=subprocess.PIPE, text=True, bufsize=1)
|
|
65
|
+
threading.Thread(target=self._read, daemon=True).start()
|
|
66
|
+
threading.Thread(target=self._read_errors, daemon=True).start()
|
|
67
|
+
|
|
68
|
+
def _read(self):
|
|
69
|
+
for line in self.process.stdout:
|
|
70
|
+
try:
|
|
71
|
+
value = json.loads(line)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
continue
|
|
74
|
+
self.messages.append(value)
|
|
75
|
+
self.lines.put(value)
|
|
76
|
+
|
|
77
|
+
def _read_errors(self):
|
|
78
|
+
for line in self.process.stderr:
|
|
79
|
+
self.errors.append(line.rstrip())
|
|
80
|
+
|
|
81
|
+
def wait(self, condition, seconds=8):
|
|
82
|
+
limit = time.monotonic() + seconds
|
|
83
|
+
for message in self.messages:
|
|
84
|
+
if condition(message):
|
|
85
|
+
return message
|
|
86
|
+
while time.monotonic() < limit:
|
|
87
|
+
try:
|
|
88
|
+
message = self.lines.get(timeout=max(.01, min(.2, limit - time.monotonic())))
|
|
89
|
+
except queue.Empty:
|
|
90
|
+
continue
|
|
91
|
+
if condition(message):
|
|
92
|
+
return message
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
def send(self, value):
|
|
96
|
+
self.process.stdin.write(json.dumps(value) + '\n')
|
|
97
|
+
self.process.stdin.flush()
|
|
98
|
+
|
|
99
|
+
def close(self):
|
|
100
|
+
if self.process.poll() is None:
|
|
101
|
+
self.process.terminate()
|
|
102
|
+
try:
|
|
103
|
+
self.process.wait(timeout=2)
|
|
104
|
+
except subprocess.TimeoutExpired:
|
|
105
|
+
self.process.kill()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def command(env, actor, verb, thread='', payload=None):
|
|
109
|
+
args = ['node', CLIENT, 'committee', verb]
|
|
110
|
+
if thread:
|
|
111
|
+
args.append(thread)
|
|
112
|
+
body = None
|
|
113
|
+
if payload is not None:
|
|
114
|
+
args += ['--input', '-']
|
|
115
|
+
body = json.dumps(payload)
|
|
116
|
+
return subprocess.run(
|
|
117
|
+
args, input=body, capture_output=True, text=True,
|
|
118
|
+
env=dict(env, CITY_BUS_ACTOR=actor), timeout=12)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def publish_activity(env, actor, payload, thread=''):
|
|
122
|
+
args = ['node', CLIENT, 'activity', 'publish']
|
|
123
|
+
if thread:
|
|
124
|
+
args.append(thread)
|
|
125
|
+
args += ['--input', '-']
|
|
126
|
+
return subprocess.run(
|
|
127
|
+
args, input=json.dumps(payload), capture_output=True, text=True,
|
|
128
|
+
env=dict(env, CITY_BUS_ACTOR=actor), timeout=12)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def claude_hook(env, actor, event, payload):
|
|
132
|
+
return subprocess.run(
|
|
133
|
+
[CLAUDE_ACTIVITY, event], input=json.dumps(payload), capture_output=True,
|
|
134
|
+
text=True, env=dict(env, CITY_BUS_ACTOR=actor,
|
|
135
|
+
CLAUDE_PLUGIN_ROOT=os.path.join(RAIZ, 'plugin')),
|
|
136
|
+
timeout=12)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def value(result):
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(result.stdout)
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def spectator_url(endpoint, token=None):
|
|
147
|
+
parsed = urllib.parse.urlparse(endpoint['url'])
|
|
148
|
+
query = urllib.parse.urlencode({
|
|
149
|
+
'mode': 'spectator',
|
|
150
|
+
'token': token if token is not None else endpoint['spectatorToken'],
|
|
151
|
+
})
|
|
152
|
+
return urllib.parse.urlunparse(parsed._replace(query=query))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def rejected(url, origin='http://127.0.0.1:43210'):
|
|
156
|
+
return subprocess.run(
|
|
157
|
+
['node', '--input-type=commonjs', '-e', REJECT_JS, url, origin],
|
|
158
|
+
cwd=CHANNEL_DIR, capture_output=True, text=True, timeout=6).returncode == 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def events_from(message):
|
|
162
|
+
return message.get('events', []) if message.get('type') == 'activity.state' else []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ordered(actual, expected):
|
|
166
|
+
cursor = 0
|
|
167
|
+
for item in actual:
|
|
168
|
+
if cursor < len(expected) and item == expected[cursor]:
|
|
169
|
+
cursor += 1
|
|
170
|
+
return cursor == len(expected)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main():
|
|
174
|
+
print('\n live browser committee feed over the real local WebSocket hub')
|
|
175
|
+
base = tempfile.mkdtemp(prefix='agents-city-live-feed-')
|
|
176
|
+
app = os.path.join(base, 'app')
|
|
177
|
+
city = os.path.join(app, 'alice', 'home')
|
|
178
|
+
os.makedirs(city)
|
|
179
|
+
open(os.path.join(city, 'city.yml'), 'w', encoding='utf-8').write(
|
|
180
|
+
'id: city_live_feed\nname: Home\nslug: home\nowner: alice\n')
|
|
181
|
+
open(os.path.join(city, 'roads.json'), 'w', encoding='utf-8').write(
|
|
182
|
+
'{"version": 1, "roads": []}\n')
|
|
183
|
+
open(os.path.join(city, 'alice.md'), 'w', encoding='utf-8').write(
|
|
184
|
+
'---\nuser: alice\nagent: alice/ceo\nrepos: [api, web, ops]\n'
|
|
185
|
+
'role.api: data-engineer\nrole.web: seo\nrole.ops: quality\n'
|
|
186
|
+
'runs.api: claude\nruns.web: codex\nruns.ops: opencode\n---\n')
|
|
187
|
+
env = dict(os.environ, AGENTS_CITY_HOME=app, AGENTS_CITY_DATA=city,
|
|
188
|
+
AGENTS_CITY_USER='alice')
|
|
189
|
+
for key in ('CITY_BUS_URL', 'CITY_BUS_TOKEN', 'CITY_DIR'):
|
|
190
|
+
env.pop(key, None)
|
|
191
|
+
|
|
192
|
+
watcher = reconnect = None
|
|
193
|
+
try:
|
|
194
|
+
opened = command(env, 'seat', 'open', payload={
|
|
195
|
+
'question': 'Do we release the migration?',
|
|
196
|
+
'desiredOutcome': 'A verified go or no-go',
|
|
197
|
+
'definitionOfDone': ['rollback reproduced', 'decision verified'],
|
|
198
|
+
'participants': ['api', 'web'],
|
|
199
|
+
'authority': 'decide',
|
|
200
|
+
'maxRebuttals': 2,
|
|
201
|
+
})
|
|
202
|
+
state = value(opened)
|
|
203
|
+
thread = state.get('id', '')
|
|
204
|
+
endpoint_path = os.path.join(
|
|
205
|
+
app, '.runtime', 'bus', 'city-live-feed', 'endpoint.json')
|
|
206
|
+
endpoint = json.load(open(endpoint_path, encoding='utf-8'))
|
|
207
|
+
afirma('· a committee starts the real local hub with a rotated spectator token',
|
|
208
|
+
opened.returncode == 0 and bool(thread) and bool(endpoint.get('spectatorToken')),
|
|
209
|
+
opened.stderr.strip())
|
|
210
|
+
|
|
211
|
+
watcher = Watcher(spectator_url(endpoint))
|
|
212
|
+
snapshot = watcher.wait(lambda message: message.get('type') == 'activity.state')
|
|
213
|
+
afirma('· the browser receives its initial state over WebSocket',
|
|
214
|
+
any(event.get('kind') == 'committee.opened'
|
|
215
|
+
for event in events_from(snapshot)),
|
|
216
|
+
'\n'.join(watcher.errors[-3:]))
|
|
217
|
+
|
|
218
|
+
# Normal runtime conversation is first-class activity too; the old
|
|
219
|
+
# implementation only mirrored committee.* events and left City live
|
|
220
|
+
# blank while the seat visibly answered in its terminal.
|
|
221
|
+
source_id = 'test-normal-seat-prompt-1'
|
|
222
|
+
normal = publish_activity(env, 'seat', {
|
|
223
|
+
'sourceId': source_id,
|
|
224
|
+
'kind': 'conversation.user', 'phase': 'asked', 'tone': 'question',
|
|
225
|
+
'title': 'seat asked', 'summary': 'What is this city for?',
|
|
226
|
+
# These are deliberately ignored: identity is the socket credential.
|
|
227
|
+
'actor': 'web', 'role': 'member',
|
|
228
|
+
}, 'runtime-thread-1')
|
|
229
|
+
visible_prompt = watcher.wait(
|
|
230
|
+
lambda message: message.get('type') == 'activity.event'
|
|
231
|
+
and message.get('event', {}).get('sourceId') == source_id)
|
|
232
|
+
prompt_event = visible_prompt.get('event', {})
|
|
233
|
+
afirma('· happy: a normal seat prompt appears immediately over WebSocket',
|
|
234
|
+
normal.returncode == 0
|
|
235
|
+
and prompt_event.get('summary') == 'What is this city for?'
|
|
236
|
+
and prompt_event.get('actor') == 'seat'
|
|
237
|
+
and prompt_event.get('role') == 'chair',
|
|
238
|
+
normal.stderr or json.dumps(visible_prompt))
|
|
239
|
+
|
|
240
|
+
# Provider reconnects and repeated hooks can report the same completed
|
|
241
|
+
# item. A stable source id makes that replay idempotent.
|
|
242
|
+
repeated = publish_activity(env, 'seat', {
|
|
243
|
+
'sourceId': source_id,
|
|
244
|
+
'kind': 'conversation.user', 'phase': 'asked', 'tone': 'question',
|
|
245
|
+
'title': 'seat asked again', 'summary': 'DUPLICATE MUST NOT APPEAR',
|
|
246
|
+
}, 'runtime-thread-1')
|
|
247
|
+
activity_file = os.path.join(
|
|
248
|
+
app, '.runtime', 'bus', 'city-live-feed', 'activity.jsonl')
|
|
249
|
+
persisted = [json.loads(line) for line in open(activity_file, encoding='utf-8')
|
|
250
|
+
if line.strip()]
|
|
251
|
+
matching = [event for event in persisted if event.get('sourceId') == source_id]
|
|
252
|
+
afirma('· happy: replaying one provider item does not duplicate the feed',
|
|
253
|
+
repeated.returncode == 0 and len(matching) == 1
|
|
254
|
+
and 'DUPLICATE MUST NOT APPEAR' not in json.dumps(matching),
|
|
255
|
+
repeated.stderr or json.dumps(matching))
|
|
256
|
+
|
|
257
|
+
# Claude supplies visible prompt/final-answer fields through documented
|
|
258
|
+
# hooks. Both travel through the exact same authenticated bus.
|
|
259
|
+
transcript = os.path.join(base, 'claude-transcript.jsonl')
|
|
260
|
+
open(transcript, 'w', encoding='utf-8').write('{"visible":true}\n')
|
|
261
|
+
hook_prompt = claude_hook(env, 'api', 'UserPromptSubmit', {
|
|
262
|
+
'session_id': 'claude-visible-session', 'transcript_path': transcript,
|
|
263
|
+
'prompt': 'Inspect the migration rollback.',
|
|
264
|
+
})
|
|
265
|
+
hook_answer = claude_hook(env, 'api', 'Stop', {
|
|
266
|
+
'session_id': 'claude-visible-session', 'transcript_path': transcript,
|
|
267
|
+
'last_assistant_message': 'Rollback fixture passes cleanly.',
|
|
268
|
+
})
|
|
269
|
+
claude_visible = watcher.wait(
|
|
270
|
+
lambda message: message.get('type') == 'activity.event'
|
|
271
|
+
and message.get('event', {}).get('kind') == 'conversation.agent'
|
|
272
|
+
and message.get('event', {}).get('actor') == 'api')
|
|
273
|
+
afirma('· happy: Claude visible prompts and answers use the WebSocket feed',
|
|
274
|
+
hook_prompt.returncode == 0 and hook_answer.returncode == 0
|
|
275
|
+
and 'Rollback fixture passes cleanly.' in json.dumps(claude_visible),
|
|
276
|
+
hook_prompt.stderr + hook_answer.stderr + json.dumps(claude_visible))
|
|
277
|
+
|
|
278
|
+
# A Channel assignment is transport, not dialogue. The prompt wrapper
|
|
279
|
+
# must stay out of the spectator feed, while Claude's visible answer is
|
|
280
|
+
# grouped into the semantic committee thread left by the channel.
|
|
281
|
+
marker_dir = os.path.join(
|
|
282
|
+
app, '.runtime', 'bus', 'city-live-feed', 'claude-threads')
|
|
283
|
+
os.makedirs(marker_dir, exist_ok=True)
|
|
284
|
+
marker_path = os.path.join(marker_dir, 'api.json')
|
|
285
|
+
open(marker_path, 'w', encoding='utf-8').write(json.dumps({
|
|
286
|
+
'thread': thread, 'envelopeId': 'assignment-1',
|
|
287
|
+
'kind': 'committee.assignment',
|
|
288
|
+
}))
|
|
289
|
+
internal_prompt = (
|
|
290
|
+
'<channel source="plugin:city:city-bus" '
|
|
291
|
+
'protocol="agents-city-bus/2">SECRET_TRANSPORT_WRAPPER</channel>')
|
|
292
|
+
internal = claude_hook(env, 'api', 'UserPromptSubmit', {
|
|
293
|
+
'session_id': 'claude-committee-session', 'transcript_path': transcript,
|
|
294
|
+
'prompt': internal_prompt,
|
|
295
|
+
})
|
|
296
|
+
internal_answer = claude_hook(env, 'api', 'Stop', {
|
|
297
|
+
'session_id': 'claude-committee-session', 'transcript_path': transcript,
|
|
298
|
+
'last_assistant_message': 'API recommends a reversible migration.',
|
|
299
|
+
})
|
|
300
|
+
grouped_answer = watcher.wait(
|
|
301
|
+
lambda message: message.get('type') == 'activity.event'
|
|
302
|
+
and message.get('event', {}).get('summary')
|
|
303
|
+
== 'API recommends a reversible migration.')
|
|
304
|
+
afirma('· happy: a Claude Channel answer joins the committee conversation',
|
|
305
|
+
internal.returncode == 0 and internal_answer.returncode == 0
|
|
306
|
+
and grouped_answer.get('event', {}).get('thread') == thread,
|
|
307
|
+
internal.stderr + internal_answer.stderr + json.dumps(grouped_answer))
|
|
308
|
+
afirma('· non-happy: raw Channel transport is neither shown nor persisted',
|
|
309
|
+
'SECRET_TRANSPORT_WRAPPER' not in json.dumps(watcher.messages)
|
|
310
|
+
and 'SECRET_TRANSPORT_WRAPPER'
|
|
311
|
+
not in open(activity_file, encoding='utf-8').read()
|
|
312
|
+
and not os.path.exists(marker_path),
|
|
313
|
+
open(activity_file, encoding='utf-8').read())
|
|
314
|
+
|
|
315
|
+
private = 'PRIVATE_REASONING_MUST_NEVER_REACH_CITY_LIVE'
|
|
316
|
+
rejected_private = publish_activity(env, 'seat', {
|
|
317
|
+
'sourceId': 'private-reasoning-1',
|
|
318
|
+
'kind': 'conversation.reasoning', 'title': 'hidden thought',
|
|
319
|
+
'summary': private,
|
|
320
|
+
}, 'runtime-thread-1')
|
|
321
|
+
afirma('· non-happy: private reasoning is rejected, not logged as activity',
|
|
322
|
+
rejected_private.returncode != 0
|
|
323
|
+
and 'private model reasoning' in rejected_private.stderr
|
|
324
|
+
and private not in open(activity_file, encoding='utf-8').read(),
|
|
325
|
+
rejected_private.stderr)
|
|
326
|
+
|
|
327
|
+
invalid = publish_activity(env, 'seat', {
|
|
328
|
+
'kind': 'committee.decision.recorded', 'title': 'forged decision',
|
|
329
|
+
'summary': 'A runtime tried to forge committee state.',
|
|
330
|
+
}, 'runtime-thread-1')
|
|
331
|
+
afirma('· non-happy: runtime activity cannot forge committee protocol events',
|
|
332
|
+
invalid.returncode != 0
|
|
333
|
+
and 'must describe conversation, runtime or work' in invalid.stderr,
|
|
334
|
+
invalid.stderr)
|
|
335
|
+
|
|
336
|
+
secret = 'SECRET_FIRST_POSITION_VISIBLE_ONLY_AFTER_BARRIER'
|
|
337
|
+
command(env, 'api', 'respond', thread, {
|
|
338
|
+
'stance': 'conditional', 'recommendation': secret,
|
|
339
|
+
'evidence': ['api migration replay 482'],
|
|
340
|
+
})
|
|
341
|
+
first = watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
342
|
+
and message.get('event', {}).get('kind')
|
|
343
|
+
== 'committee.position.submitted')
|
|
344
|
+
afirma('· the live feed shows progress but does not leak an early position',
|
|
345
|
+
bool(first) and secret not in json.dumps(watcher.messages),
|
|
346
|
+
json.dumps(first))
|
|
347
|
+
|
|
348
|
+
command(env, 'web', 'respond', thread, {
|
|
349
|
+
'stance': 'support', 'recommendation': 'ship with a cache-safe rollback banner',
|
|
350
|
+
'evidence': ['web end-to-end rollback passed'],
|
|
351
|
+
})
|
|
352
|
+
barrier = watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
353
|
+
and message.get('event', {}).get('kind')
|
|
354
|
+
== 'committee.positions.revealed')
|
|
355
|
+
watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
356
|
+
and message.get('event', {}).get('kind')
|
|
357
|
+
== 'committee.position.revealed'
|
|
358
|
+
and message.get('event', {}).get('actor') == 'web')
|
|
359
|
+
revealed = [message.get('event', {}) for message in watcher.messages
|
|
360
|
+
if message.get('type') == 'activity.event'
|
|
361
|
+
and message.get('event', {}).get('kind')
|
|
362
|
+
== 'committee.position.revealed']
|
|
363
|
+
afirma('· after the barrier each specialist speaks as a visible participant',
|
|
364
|
+
len(revealed) == 2
|
|
365
|
+
and secret in json.dumps(revealed)
|
|
366
|
+
and 'cache-safe rollback banner' in json.dumps(revealed)
|
|
367
|
+
and secret not in json.dumps(barrier),
|
|
368
|
+
json.dumps(revealed))
|
|
369
|
+
|
|
370
|
+
command(env, 'seat', 'synthesize', thread, {
|
|
371
|
+
'summary': 'Both paths support a reversible release',
|
|
372
|
+
'agreements': ['rollback first'], 'conflicts': ['banner cache'],
|
|
373
|
+
'unknowns': [],
|
|
374
|
+
})
|
|
375
|
+
requested = value(command(env, 'api', 'floor-request', thread, {
|
|
376
|
+
'basis': 'new_evidence', 'reason': 'the clean replay just completed',
|
|
377
|
+
'evidence': ['immutable run 482 passed'],
|
|
378
|
+
}))
|
|
379
|
+
request_id = requested.get('myFloorRequests', [{}])[-1].get('id', '')
|
|
380
|
+
command(env, 'seat', 'floor-grant', thread, {'requestId': request_id})
|
|
381
|
+
command(env, 'api', 'reply', thread, {
|
|
382
|
+
'claim': 'the migration replay is now green',
|
|
383
|
+
'evidence': ['immutable run 482'],
|
|
384
|
+
'consequence': 'the migration gate is satisfied',
|
|
385
|
+
})
|
|
386
|
+
spoke = watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
387
|
+
and message.get('event', {}).get('kind')
|
|
388
|
+
== 'committee.floor.spoke')
|
|
389
|
+
afirma('· a requested and granted intervention appears live with its evidence',
|
|
390
|
+
'migration replay is now green' in json.dumps(spoke)
|
|
391
|
+
and 'immutable run 482' in json.dumps(spoke), json.dumps(spoke))
|
|
392
|
+
|
|
393
|
+
web_outbox = os.path.join(
|
|
394
|
+
app, '.runtime', 'bus', 'city-live-feed', 'outbox', 'web')
|
|
395
|
+
queued = []
|
|
396
|
+
for name in os.listdir(web_outbox):
|
|
397
|
+
if name.endswith('.json'):
|
|
398
|
+
queued.append(json.load(open(os.path.join(web_outbox, name), encoding='utf-8')))
|
|
399
|
+
heard = next((item for item in queued if item.get('kind') == 'committee.reply.heard'), {})
|
|
400
|
+
afirma('· every other member hears the chaired intervention and may seek a reply',
|
|
401
|
+
heard.get('payload', {}).get('reply', {}).get('actor') == 'api'
|
|
402
|
+
and 'request the floor' in heard.get('payload', {}).get('note', '').lower(),
|
|
403
|
+
json.dumps(heard))
|
|
404
|
+
|
|
405
|
+
web_request = value(command(env, 'web', 'floor-request', thread, {
|
|
406
|
+
'basis': 'contradiction', 'reason': 'the CDN still serves the old banner',
|
|
407
|
+
'evidence': ['edge trace cache-age=310'],
|
|
408
|
+
}))
|
|
409
|
+
web_request_id = web_request.get('myFloorRequests', [{}])[-1].get('id', '')
|
|
410
|
+
blocked = command(env, 'seat', 'decide', thread, {
|
|
411
|
+
'outcome': 'ship', 'rationale': 'replay passed', 'owner': 'alice',
|
|
412
|
+
'executor': 'api', 'verifier': 'ops',
|
|
413
|
+
'verificationQuestion': 'does rollback pass?',
|
|
414
|
+
'selectedEvidence': ['run 482'],
|
|
415
|
+
'decisiveContributors': ['api', 'web'], 'reopenIf': ['rollback fails'],
|
|
416
|
+
})
|
|
417
|
+
rejected_event = watcher.wait(
|
|
418
|
+
lambda message: message.get('type') == 'activity.event'
|
|
419
|
+
and message.get('event', {}).get('kind') == 'committee.command.rejected')
|
|
420
|
+
afirma('· an illegal early decision is rejected and visibly explained',
|
|
421
|
+
blocked.returncode != 0 and 'pending floor request' in blocked.stderr
|
|
422
|
+
and 'pending floor request' in json.dumps(rejected_event),
|
|
423
|
+
blocked.stderr.strip())
|
|
424
|
+
command(env, 'seat', 'floor-deny', thread, {
|
|
425
|
+
'requestId': web_request_id,
|
|
426
|
+
'reason': 'the verifier will reproduce that exact cache trace',
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
command(env, 'seat', 'decide', thread, {
|
|
430
|
+
'outcome': 'ship after clean rollback',
|
|
431
|
+
'rationale': 'migration evidence is green and cache risk is assigned',
|
|
432
|
+
'owner': 'alice', 'executor': 'api', 'verifier': 'ops',
|
|
433
|
+
'verificationQuestion': 'do clean rollback and cache checks pass?',
|
|
434
|
+
'selectedEvidence': ['run 482', 'web rollback e2e'],
|
|
435
|
+
'decisiveContributors': ['api', 'web'],
|
|
436
|
+
'dissent': ['web retains a cache concern'], 'reopenIf': ['cache check fails'],
|
|
437
|
+
})
|
|
438
|
+
command(env, 'ops', 'verify', thread, {
|
|
439
|
+
'result': 'pass', 'evidence': ['clean replay 483'],
|
|
440
|
+
'checks': ['rollback', 'CDN cache'], 'residualRisks': [],
|
|
441
|
+
})
|
|
442
|
+
command(env, 'seat', 'close', thread, {
|
|
443
|
+
'summary': 'Release authorised after independent verification',
|
|
444
|
+
'learnings': ['cache belongs in the rollback fixture'], 'followups': [],
|
|
445
|
+
})
|
|
446
|
+
closed = watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
447
|
+
and message.get('event', {}).get('kind') == 'committee.closed')
|
|
448
|
+
afirma('· the happy path reaches a verified, visible closure',
|
|
449
|
+
'Release authorised' in json.dumps(closed), json.dumps(closed))
|
|
450
|
+
|
|
451
|
+
watcher.send({
|
|
452
|
+
'type': 'command', 'requestId': 'browser-write',
|
|
453
|
+
'command': 'committee.cancel', 'thread': thread,
|
|
454
|
+
'payload': {'reason': 'browser tried to take control'},
|
|
455
|
+
})
|
|
456
|
+
read_only = watcher.wait(lambda message: message.get('type') == 'result'
|
|
457
|
+
and message.get('requestId') == 'browser-write')
|
|
458
|
+
afirma('· the browser WebSocket is a mirror and cannot mutate the committee',
|
|
459
|
+
read_only.get('ok') is False
|
|
460
|
+
and 'read-only' in read_only.get('error', ''), json.dumps(read_only))
|
|
461
|
+
|
|
462
|
+
afirma('· a guessed spectator token is refused at the WebSocket handshake',
|
|
463
|
+
rejected(spectator_url(endpoint, 'guessed-token')))
|
|
464
|
+
afirma('· even a valid token is refused from a non-local browser origin',
|
|
465
|
+
rejected(spectator_url(endpoint), 'https://evil.example'))
|
|
466
|
+
|
|
467
|
+
reconnect = Watcher(spectator_url(endpoint))
|
|
468
|
+
restored = reconnect.wait(lambda message: message.get('type') == 'activity.state')
|
|
469
|
+
restored_events = events_from(restored)
|
|
470
|
+
kinds = [event.get('kind') for event in restored_events
|
|
471
|
+
if event.get('thread') == thread]
|
|
472
|
+
expected = [
|
|
473
|
+
'committee.opened', 'committee.position.submitted',
|
|
474
|
+
'committee.position.submitted', 'committee.positions.revealed',
|
|
475
|
+
'committee.position.revealed', 'committee.position.revealed',
|
|
476
|
+
'committee.synthesis.published', 'committee.floor.requested',
|
|
477
|
+
'committee.floor.granted', 'committee.floor.spoke',
|
|
478
|
+
'committee.floor.requested', 'committee.command.rejected',
|
|
479
|
+
'committee.floor.denied', 'committee.decision.recorded',
|
|
480
|
+
'committee.verification.pass', 'committee.closed',
|
|
481
|
+
]
|
|
482
|
+
afirma('· reconnect restores the ordered conversation without polling',
|
|
483
|
+
ordered(kinds, expected), str(kinds))
|
|
484
|
+
seqs = [event.get('seq') for event in restored_events]
|
|
485
|
+
afirma('· persisted feed sequence is monotonic and duplicate-free',
|
|
486
|
+
seqs == sorted(set(seqs)), str(seqs))
|
|
487
|
+
afirma('· the same semantic feed is durable for browser reloads',
|
|
488
|
+
os.path.isfile(activity_file)
|
|
489
|
+
and 'committee.floor.spoke' in open(activity_file, encoding='utf-8').read())
|
|
490
|
+
diagnostic_file = os.path.join(
|
|
491
|
+
app, '.runtime', 'bus', 'city-live-feed', 'diagnostics.jsonl')
|
|
492
|
+
diagnostic_text = open(diagnostic_file, encoding='utf-8').read()
|
|
493
|
+
afirma('· non-happy failures leave durable diagnostics without credentials',
|
|
494
|
+
'command.rejected' in diagnostic_text
|
|
495
|
+
and endpoint['spectatorToken'] not in diagnostic_text,
|
|
496
|
+
diagnostic_text[-1000:])
|
|
497
|
+
finally:
|
|
498
|
+
if watcher:
|
|
499
|
+
watcher.close()
|
|
500
|
+
if reconnect:
|
|
501
|
+
reconnect.close()
|
|
502
|
+
detiene_hubs_de_ciudad(city)
|
|
503
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
504
|
+
return resumen('live-feed')
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
if __name__ == '__main__':
|
|
508
|
+
sys.exit(main())
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The Hall, in a real browser that clicks.
|
|
3
|
+
|
|
4
|
+
./bin/test-navegador.py
|
|
5
|
+
|
|
6
|
+
Every other suite in this repo can pass while the page is dead. The instruction
|
|
7
|
+
editors and the skill upload once shipped rendered, typechecked, and wired to
|
|
8
|
+
nothing at all: `tsc` cannot see a handler that was never attached, and a
|
|
9
|
+
DOM-free assertion cannot click. This suite is the answer to that class of bug,
|
|
10
|
+
and it exists because that bug shipped.
|
|
11
|
+
|
|
12
|
+
It serves a real Hall over loopback with a throwaway city behind it, drives
|
|
13
|
+
headless Chrome over the DevTools protocol (`bin/navegador.mjs`, no driver
|
|
14
|
+
library), and asserts that the controls DO something. Without a browser on the
|
|
15
|
+
machine it says so and passes; CI sets CITY_BROWSER_REQUIRED=1 so a runner that
|
|
16
|
+
lost its Chrome fails loudly instead of quietly testing nothing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import threading
|
|
25
|
+
|
|
26
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
RAIZ = os.path.dirname(AQUI)
|
|
28
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
29
|
+
sys.path.insert(0, AQUI)
|
|
30
|
+
|
|
31
|
+
import serve # noqa: E402
|
|
32
|
+
from testlib import afirma, resumen # noqa: E402
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ciudad_de_prueba(datos):
|
|
36
|
+
"""A city with one agent in it, so the sheets have something to draw."""
|
|
37
|
+
with open(os.path.join(datos, "city.yml"), "w", encoding="utf-8") as f:
|
|
38
|
+
f.write("id: city_browser_v1\nname: Browser Test\nslug: browser\nowner: navtest\n")
|
|
39
|
+
taller = os.path.join(datos, "agents", "notas")
|
|
40
|
+
os.makedirs(taller, exist_ok=True)
|
|
41
|
+
with open(os.path.join(taller, "apuntes.md"), "w", encoding="utf-8") as f:
|
|
42
|
+
f.write("# notas\n")
|
|
43
|
+
with open(os.path.join(datos, "navtest.md"), "w", encoding="utf-8") as f:
|
|
44
|
+
f.write(
|
|
45
|
+
"---\n"
|
|
46
|
+
"user: navtest\nname: Nav Test\nrole: cpto\nagent: navtest/seat\n"
|
|
47
|
+
"agents: [notas]\nkind.notas: knowledge\nrole.notas: apuntes\n"
|
|
48
|
+
"goals_defined: false\n---\n\n# navtest\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bundle_al_dia():
|
|
53
|
+
"""Rebuild the Hall bundle when this machine can, so the browser drives the
|
|
54
|
+
CURRENT source.
|
|
55
|
+
|
|
56
|
+
The bundles are committed so an install needs no build step, which means a
|
|
57
|
+
stale one is exactly what a browser test would happily pass against — the
|
|
58
|
+
bug it exists to catch, one level down. Where esbuild is not installed the
|
|
59
|
+
committed bundle is what ships, and testing that is honest too.
|
|
60
|
+
"""
|
|
61
|
+
web = os.path.join(RAIZ, "city", "web")
|
|
62
|
+
if not os.path.isdir(os.path.join(web, "node_modules", "esbuild")):
|
|
63
|
+
return "committed bundle (no esbuild here)"
|
|
64
|
+
hecho = subprocess.run(
|
|
65
|
+
[
|
|
66
|
+
os.path.join(web, "node_modules", ".bin", "esbuild"),
|
|
67
|
+
os.path.join(web, "src", "hall.ts"),
|
|
68
|
+
"--bundle",
|
|
69
|
+
"--format=esm",
|
|
70
|
+
"--outfile=" + os.path.join(web, "dist-hall", "hall.js"),
|
|
71
|
+
],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=180,
|
|
75
|
+
)
|
|
76
|
+
if hecho.returncode != 0:
|
|
77
|
+
return "committed bundle (build failed)"
|
|
78
|
+
return "rebuilt from src/hall.ts"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
print()
|
|
83
|
+
print(" the hall, in a browser that clicks")
|
|
84
|
+
print(f" · {bundle_al_dia()}")
|
|
85
|
+
datos = tempfile.mkdtemp()
|
|
86
|
+
casa = tempfile.mkdtemp()
|
|
87
|
+
serve.cities.REGISTRO = os.path.join(tempfile.mkdtemp(), "cities")
|
|
88
|
+
previo = {k: os.environ.get(k) for k in ("AGENTS_CITY_DATA", "AGENTS_CITY_HOME",
|
|
89
|
+
"AGENTS_CITY_USER", "CITY_SEARCH_IN")}
|
|
90
|
+
os.environ.update(
|
|
91
|
+
AGENTS_CITY_DATA=datos,
|
|
92
|
+
AGENTS_CITY_HOME=casa,
|
|
93
|
+
AGENTS_CITY_USER="navtest",
|
|
94
|
+
CITY_SEARCH_IN=tempfile.mkdtemp(),
|
|
95
|
+
)
|
|
96
|
+
ciudad_de_prueba(datos)
|
|
97
|
+
|
|
98
|
+
servidor = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
|
|
99
|
+
hilo = threading.Thread(target=servidor.serve_forever, daemon=True)
|
|
100
|
+
hilo.start()
|
|
101
|
+
url = f"http://127.0.0.1:{servidor.server_port}/?PASE={serve.PASE}"
|
|
102
|
+
try:
|
|
103
|
+
salida = subprocess.run(
|
|
104
|
+
["node", os.path.join(AQUI, "navegador.mjs"), url],
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
timeout=300,
|
|
108
|
+
)
|
|
109
|
+
texto = (salida.stdout or "") + (salida.stderr or "")
|
|
110
|
+
if "skipped" in texto and salida.returncode == 0:
|
|
111
|
+
print(" no browser on this machine — the clicking checks were skipped")
|
|
112
|
+
print("\n navegador ok — 0 checks (no browser on this machine)\n")
|
|
113
|
+
return 0
|
|
114
|
+
# Each browser check counts as one here: a suite that reports "1 check"
|
|
115
|
+
# for eleven assertions hides both its coverage and which one broke.
|
|
116
|
+
vistas = 0
|
|
117
|
+
for linea in texto.splitlines():
|
|
118
|
+
limpia = linea.strip()
|
|
119
|
+
if limpia.startswith("ok ·") or limpia.startswith("FAIL·"):
|
|
120
|
+
vistas += 1
|
|
121
|
+
afirma("· " + limpia.split("·", 1)[1].strip(), limpia.startswith("ok"), "")
|
|
122
|
+
if not vistas:
|
|
123
|
+
afirma("· the browser checks ran at all", False, texto.strip()[-500:])
|
|
124
|
+
elif salida.returncode != 0:
|
|
125
|
+
afirma("· the browser driver finished cleanly", False, texto.strip()[-300:])
|
|
126
|
+
finally:
|
|
127
|
+
servidor.shutdown()
|
|
128
|
+
servidor.server_close()
|
|
129
|
+
for k, v in previo.items():
|
|
130
|
+
if v is None:
|
|
131
|
+
os.environ.pop(k, None)
|
|
132
|
+
else:
|
|
133
|
+
os.environ[k] = v
|
|
134
|
+
shutil.rmtree(datos, ignore_errors=True)
|
|
135
|
+
shutil.rmtree(casa, ignore_errors=True)
|
|
136
|
+
return resumen("navegador")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
sys.exit(main())
|