agents-city 0.3.0-beta.21
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 +2021 -0
- package/README.md +1999 -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 +116 -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/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +426 -0
- package/bin/logs +3 -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 +1428 -0
- package/bin/setup.py +753 -0
- package/bin/skills +3 -0
- package/bin/test +131 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -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 +206 -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 +544 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +106 -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-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 +1386 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1057 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +188 -0
- package/bin/testlib.py +95 -0
- package/bin/tokens.py +14 -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 +264 -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 +1867 -0
- package/city/web/src/main.ts +2225 -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 +99 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +231 -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/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +252 -0
- package/plugin/scripts/capabilities.py +197 -0
- package/plugin/scripts/card.py +312 -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 +526 -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 +172 -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 +51 -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 +1349 -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 +364 -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
package/bin/test-seat.py
ADDED
|
@@ -0,0 +1,1386 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""What `./bin/seat` does on a machine that is not yours.
|
|
3
|
+
|
|
4
|
+
./bin/test-seat.py
|
|
5
|
+
|
|
6
|
+
The reason this exists: the front door runs on somebody else's laptop, with their
|
|
7
|
+
git config, their folder names, their shell, their OS, and their tmux version.
|
|
8
|
+
Every one of those has already produced a silent failure in this repo —
|
|
9
|
+
|
|
10
|
+
* `tr '[:upper:]_' '[:lower:]-'` on a repo called `My_Repo`
|
|
11
|
+
* a git email of `12345678+alice@users.noreply.github.com`
|
|
12
|
+
* `tmux set-option -g` on options that are *window* options, which fails quietly
|
|
13
|
+
and left half the notification block doing nothing
|
|
14
|
+
* `AGENTS_CITY_DATA` never reaching the windows, because a window inherits the
|
|
15
|
+
tmux *server's* environment and not the caller's
|
|
16
|
+
|
|
17
|
+
None of those raise. They just quietly do the wrong thing, which is why the unhappy
|
|
18
|
+
half of this file is longer than the happy half.
|
|
19
|
+
|
|
20
|
+
Nothing here launches tmux or Claude. What is being tested is every decision taken
|
|
21
|
+
before the handover: who you are, where the card goes, what gets written, and what
|
|
22
|
+
is said when a machine cannot do this at all.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import importlib.machinery as mach
|
|
26
|
+
import importlib.util as iu
|
|
27
|
+
import base64
|
|
28
|
+
import os
|
|
29
|
+
import shlex
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
|
|
35
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
36
|
+
RAIZ = os.path.dirname(AQUI)
|
|
37
|
+
sys.path.insert(0, AQUI)
|
|
38
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
39
|
+
|
|
40
|
+
from testlib import comprueba, afirma, resumen # noqa: E402
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def carga_seat():
|
|
44
|
+
s = iu.spec_from_loader(
|
|
45
|
+
"seat", mach.SourceFileLoader("seat", os.path.join(RAIZ, "plugin", "scripts", "seat.py"))
|
|
46
|
+
)
|
|
47
|
+
m = iu.module_from_spec(s)
|
|
48
|
+
s.loader.exec_module(m)
|
|
49
|
+
return m
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
S = carga_seat()
|
|
53
|
+
import ui # noqa: E402
|
|
54
|
+
import card # noqa: E402 the one card reader, which seat now uses
|
|
55
|
+
import cities # noqa: E402
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class finge:
|
|
59
|
+
"""Swap a module attribute for the duration of a block."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, mod, **kw):
|
|
62
|
+
self.mod, self.kw, self.viejo = mod, kw, {}
|
|
63
|
+
|
|
64
|
+
def __enter__(self):
|
|
65
|
+
for k, v in self.kw.items():
|
|
66
|
+
self.viejo[k] = getattr(self.mod, k, None)
|
|
67
|
+
setattr(self.mod, k, v)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *_):
|
|
71
|
+
for k, v in self.viejo.items():
|
|
72
|
+
setattr(self.mod, k, v)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def con_git(correo):
|
|
76
|
+
"""`sh` as it behaves for a given git user.email — '' meaning not configured."""
|
|
77
|
+
|
|
78
|
+
def falso(args, **kw):
|
|
79
|
+
if args[:3] == ["git", "config", "user.email"]:
|
|
80
|
+
return correo + "\n" if correo else ""
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
return falso
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ══ happy path ═════════════════════════════════════════════════════════════
|
|
87
|
+
def camino_feliz():
|
|
88
|
+
print(" happy path")
|
|
89
|
+
|
|
90
|
+
with finge(S, sh=con_git("joseluis@rankia.com")):
|
|
91
|
+
comprueba("· the username comes from the git email", S.quien_soy(), "joseluis")
|
|
92
|
+
|
|
93
|
+
casa = tempfile.mkdtemp()
|
|
94
|
+
datos = os.path.join(casa, "ciudad")
|
|
95
|
+
os.makedirs(datos)
|
|
96
|
+
ficha = os.path.join(datos, "ana.md")
|
|
97
|
+
S.escribe_ficha(
|
|
98
|
+
ficha,
|
|
99
|
+
"ana",
|
|
100
|
+
"cpto",
|
|
101
|
+
["api", "web"],
|
|
102
|
+
repo_roles={"api": "data-engineer", "web": "seo"},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
texto = open(ficha).read()
|
|
106
|
+
afirma("· the card starts with frontmatter", texto.startswith("---\n"))
|
|
107
|
+
for campo, valor in (
|
|
108
|
+
("user", "ana"),
|
|
109
|
+
("role", "cpto"),
|
|
110
|
+
("agent", "ana/lead"),
|
|
111
|
+
("repos", "[api, web]"),
|
|
112
|
+
("role.api", "data-engineer"),
|
|
113
|
+
("role.web", "seo"),
|
|
114
|
+
):
|
|
115
|
+
afirma(
|
|
116
|
+
f"· the card carries {campo}: {valor}",
|
|
117
|
+
f"{campo}: {valor}" in texto,
|
|
118
|
+
f"card said: {[l for l in texto.splitlines() if l.startswith(campo + ':')]}",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# The one field the session actually reads, read the way the session reads it.
|
|
122
|
+
leido = subprocess.run(
|
|
123
|
+
["python3", os.path.join(RAIZ, "plugin", "scripts", "read-card.py"), ficha, "repos"],
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
).stdout.strip()
|
|
127
|
+
comprueba("· read-card.py gets the repos back out", leido, "api,web")
|
|
128
|
+
agente = subprocess.run(
|
|
129
|
+
["python3", os.path.join(RAIZ, "plugin", "scripts", "read-card.py"), ficha, "agent"],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
).stdout.strip()
|
|
133
|
+
comprueba(
|
|
134
|
+
"· read-card.py gets the agent back out — the field the seat window reads",
|
|
135
|
+
agente,
|
|
136
|
+
"ana/lead",
|
|
137
|
+
)
|
|
138
|
+
rol_api = subprocess.run(
|
|
139
|
+
[
|
|
140
|
+
"python3",
|
|
141
|
+
os.path.join(RAIZ, "plugin", "scripts", "read-card.py"),
|
|
142
|
+
"--actor-role",
|
|
143
|
+
ficha,
|
|
144
|
+
"api",
|
|
145
|
+
],
|
|
146
|
+
capture_output=True,
|
|
147
|
+
text=True,
|
|
148
|
+
).stdout.strip()
|
|
149
|
+
comprueba("· the api runtime gets its own role, not the chair role", rol_api, "data-engineer")
|
|
150
|
+
|
|
151
|
+
hechos = S.escribe_suelo(datos, ["api", "web"])
|
|
152
|
+
afirma("· the floor is three files", len(hechos) == 3, str(hechos))
|
|
153
|
+
for f in ("units.yml", "parcels.yml", "city.yml"):
|
|
154
|
+
afirma(f"· {f} written", os.path.exists(os.path.join(datos, f)))
|
|
155
|
+
parcels = open(os.path.join(datos, "parcels.yml")).read()
|
|
156
|
+
afirma("· every repo is a house", "api:" in parcels and "web:" in parcels)
|
|
157
|
+
afirma(
|
|
158
|
+
"· in a district that units.yml declares",
|
|
159
|
+
"unidad: mine" in parcels and "id: mine" in open(os.path.join(datos, "units.yml")).read(),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# The seeder is the real consumer of those two files. If it cannot read them the
|
|
163
|
+
# map draws nothing, and it will not say so loudly.
|
|
164
|
+
r = subprocess.run(
|
|
165
|
+
["python3", os.path.join(RAIZ, "city", "scripts", "seed.py"), "--data", datos],
|
|
166
|
+
capture_output=True,
|
|
167
|
+
text=True,
|
|
168
|
+
)
|
|
169
|
+
afirma("· the seeder reads the floor we wrote", r.returncode == 0, r.stderr[:200])
|
|
170
|
+
afirma(
|
|
171
|
+
"· and produces the two houses",
|
|
172
|
+
r.stdout.count("INSERT INTO parcela") == 2,
|
|
173
|
+
f"{r.stdout.count('INSERT INTO parcela')} parcela inserts",
|
|
174
|
+
)
|
|
175
|
+
afirma(
|
|
176
|
+
"· with an owner, which is what a city is derived from",
|
|
177
|
+
"'ana'" in r.stdout,
|
|
178
|
+
"no owner in the parcela rows",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
shutil.rmtree(casa)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ══ unhappy path ═══════════════════════════════════════════════════════════
|
|
185
|
+
def caminos_infelices():
|
|
186
|
+
print(" weird configs")
|
|
187
|
+
|
|
188
|
+
# ── who am I, when git will not say ────────────────────────────────────
|
|
189
|
+
with finge(S, sh=con_git("")):
|
|
190
|
+
u = S.quien_soy()
|
|
191
|
+
afirma("· no git email at all still yields a username", bool(u), f"got {u!r}")
|
|
192
|
+
|
|
193
|
+
casos = [
|
|
194
|
+
("Max.Carrion@Rankia.COM", "max.carrion", "mixed case and a dot"),
|
|
195
|
+
("12345678+alice@users.noreply.github.com", "alice", "GitHub's private email"),
|
|
196
|
+
("alice+work@x.com", "alice", "plus-addressing: the account is before the plus"),
|
|
197
|
+
("a+b+c@x.com", "a", "several pluses, none of them an account id"),
|
|
198
|
+
(" spaced@x.com ", "spaced", "whitespace around it"),
|
|
199
|
+
("o'brien@x.com", "obrien", "an apostrophe, which would break a shell line"),
|
|
200
|
+
("a b@x.com", "ab", "a space inside the local part"),
|
|
201
|
+
("ünïcode@x.com", "unicode", "accents transliterated, not dropped"),
|
|
202
|
+
("José.Álvarez@x.com", "jose.alvarez", "a real Spanish name"),
|
|
203
|
+
]
|
|
204
|
+
for correo, espera, por_que in casos:
|
|
205
|
+
with finge(S, sh=con_git(correo)):
|
|
206
|
+
comprueba(f"· {por_que}: {correo.strip()}", S.quien_soy(), espera)
|
|
207
|
+
|
|
208
|
+
# usuario_de_correo is shared with the wizard, so pin it here too.
|
|
209
|
+
from setup import usuario_de_correo
|
|
210
|
+
|
|
211
|
+
comprueba(
|
|
212
|
+
"· the wizard and the seat agree on GitHub noreply emails",
|
|
213
|
+
usuario_de_correo("12345678+alice@users.noreply.github.com"),
|
|
214
|
+
"alice",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# ── no repos on the disk ───────────────────────────────────────────────
|
|
218
|
+
with finge(S, repos_del_disco=lambda: [], sh=con_git("x@y.com")):
|
|
219
|
+
r = S.elige_repos("x", [])
|
|
220
|
+
comprueba("· an empty disk returns nothing instead of raising", r, None)
|
|
221
|
+
|
|
222
|
+
# ── a card that exists but is odd ──────────────────────────────────────
|
|
223
|
+
casa = tempfile.mkdtemp()
|
|
224
|
+
raros = {
|
|
225
|
+
"no-repos-field.md": "---\nuser: a\nagent: a/dev\n---\n",
|
|
226
|
+
"empty-repos.md": "---\nuser: a\nagent: a/dev\nrepos: []\n---\n",
|
|
227
|
+
"quoted.md": "---\nuser: a\nagent: a/dev\nrepos: [\"api\", 'web']\n---\n",
|
|
228
|
+
"no-frontmatter.md": "# just a heading\n",
|
|
229
|
+
"spanish-agent.md": "---\nuser: a\nagente: a/ops\nrepos: [api]\n---\n",
|
|
230
|
+
}
|
|
231
|
+
for nombre, cuerpo in raros.items():
|
|
232
|
+
open(os.path.join(casa, nombre), "w").write(cuerpo)
|
|
233
|
+
comprueba(
|
|
234
|
+
"· a card with no repos field reads as no repos",
|
|
235
|
+
S.repos_de_ficha(os.path.join(casa, "no-repos-field.md")),
|
|
236
|
+
[],
|
|
237
|
+
)
|
|
238
|
+
comprueba(
|
|
239
|
+
"· an empty list reads as no repos",
|
|
240
|
+
S.repos_de_ficha(os.path.join(casa, "empty-repos.md")),
|
|
241
|
+
[],
|
|
242
|
+
)
|
|
243
|
+
comprueba(
|
|
244
|
+
"· quotes around repo names are stripped",
|
|
245
|
+
S.repos_de_ficha(os.path.join(casa, "quoted.md")),
|
|
246
|
+
["api", "web"],
|
|
247
|
+
)
|
|
248
|
+
comprueba(
|
|
249
|
+
"· a card with no frontmatter does not crash",
|
|
250
|
+
S.repos_de_ficha(os.path.join(casa, "no-frontmatter.md")),
|
|
251
|
+
[],
|
|
252
|
+
)
|
|
253
|
+
# The bug that meant nobody's seat ever carried their role.
|
|
254
|
+
esp = os.path.join(casa, "spanish-agent.md")
|
|
255
|
+
a1 = subprocess.run(
|
|
256
|
+
["python3", os.path.join(RAIZ, "plugin", "scripts", "read-card.py"), esp, "agent"],
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
).stdout.strip()
|
|
260
|
+
a2 = subprocess.run(
|
|
261
|
+
["python3", os.path.join(RAIZ, "plugin", "scripts", "read-card.py"), esp, "agente"],
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
).stdout.strip()
|
|
265
|
+
afirma(
|
|
266
|
+
"· a hand-written `agente:` card answers to `agent` as well, so no "
|
|
267
|
+
"caller needs to ask twice",
|
|
268
|
+
a1 == "a/ops" and a2 == "a/ops",
|
|
269
|
+
f"agent={a1!r} agente={a2!r}",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# ── worktrees: one repo, several working folders ────────────────────────
|
|
273
|
+
# A linked worktree's .git is a FILE, and the scanner used to ask for a
|
|
274
|
+
# directory — so the folders agents isolate into were exactly the invisible
|
|
275
|
+
# ones. Both must index, with distinct names.
|
|
276
|
+
wt = tempfile.mkdtemp()
|
|
277
|
+
subprocess.run(["git", "init", "-q", f"{wt}/repo"], capture_output=True)
|
|
278
|
+
# -c identity, because a CI runner has none configured and a commit with no
|
|
279
|
+
# author fails silently here — which is how this test greened locally and
|
|
280
|
+
# reddened on the very first CI run.
|
|
281
|
+
subprocess.run(
|
|
282
|
+
[
|
|
283
|
+
"git",
|
|
284
|
+
"-C",
|
|
285
|
+
f"{wt}/repo",
|
|
286
|
+
"-c",
|
|
287
|
+
"user.email=t@t",
|
|
288
|
+
"-c",
|
|
289
|
+
"user.name=t",
|
|
290
|
+
"commit",
|
|
291
|
+
"-q",
|
|
292
|
+
"--allow-empty",
|
|
293
|
+
"-m",
|
|
294
|
+
"x",
|
|
295
|
+
],
|
|
296
|
+
capture_output=True,
|
|
297
|
+
)
|
|
298
|
+
subprocess.run(
|
|
299
|
+
["git", "-C", f"{wt}/repo", "remote", "add", "origin", "https://example.com/x/miapp.git"],
|
|
300
|
+
capture_output=True,
|
|
301
|
+
)
|
|
302
|
+
subprocess.run(["git", "-C", f"{wt}/repo", "branch", "feature/x"], capture_output=True)
|
|
303
|
+
subprocess.run(
|
|
304
|
+
["git", "-C", f"{wt}/repo", "worktree", "add", "-q", f"{wt}/repo-fx", "feature/x"],
|
|
305
|
+
capture_output=True,
|
|
306
|
+
)
|
|
307
|
+
indice = subprocess.run(
|
|
308
|
+
[os.path.join(RAIZ, "plugin", "scripts", "find-repos.sh")],
|
|
309
|
+
capture_output=True,
|
|
310
|
+
text=True,
|
|
311
|
+
env=dict(os.environ, CITY_SEARCH_IN=wt, XDG_CACHE_HOME=tempfile.mkdtemp()),
|
|
312
|
+
).stdout
|
|
313
|
+
nombres = {l.split("\t")[0] for l in indice.splitlines() if "\t" in l}
|
|
314
|
+
comprueba(
|
|
315
|
+
"· the clone and its worktree both index, distinctly", nombres, {"miapp", "miapp@feature/x"}
|
|
316
|
+
)
|
|
317
|
+
# And the window/engine-key slug is key-safe for pon_campo.
|
|
318
|
+
slug = subprocess.run(
|
|
319
|
+
["/bin/sh", "-c", "echo 'miapp@feature/x' | tr '[:upper:]' '[:lower:]' | tr '_@/' '---'"],
|
|
320
|
+
capture_output=True,
|
|
321
|
+
text=True,
|
|
322
|
+
).stdout.strip()
|
|
323
|
+
comprueba("· its window name is engine-key safe", slug, "miapp-feature-x")
|
|
324
|
+
import card as _card
|
|
325
|
+
|
|
326
|
+
d2, r2 = tempfile.mkdtemp(), None
|
|
327
|
+
r2 = os.path.join(d2, "x.md")
|
|
328
|
+
open(r2, "w").write("---\nuser: a\nrepos: []\ngoals_defined: false\n---\n")
|
|
329
|
+
afirma("· so a per-worktree engine can be set", _card.pon_campo(r2, f"model.{slug}", "haiku"))
|
|
330
|
+
shutil.rmtree(wt)
|
|
331
|
+
shutil.rmtree(d2)
|
|
332
|
+
|
|
333
|
+
# ── a window can run another vendor's agent ─────────────────────────────
|
|
334
|
+
# `runs.<window>: <command>` on the card launches that command verbatim
|
|
335
|
+
# instead of claude. Verified here with a marker, and by hand with the real
|
|
336
|
+
# codex CLI — the value is the whole command line, because no table of ours
|
|
337
|
+
# could keep up with every vendor's flags.
|
|
338
|
+
mixto = tempfile.mkdtemp()
|
|
339
|
+
fmix = os.path.join(mixto, "zzmix.md")
|
|
340
|
+
S.escribe_ficha(fmix, "zzmix", "dev", [], None)
|
|
341
|
+
import card as _c2
|
|
342
|
+
|
|
343
|
+
afirma(
|
|
344
|
+
"· the runs key is settable and key-safe",
|
|
345
|
+
_c2.pon_campo(fmix, "runs.dbt", "echo OTRO_AGENTE && sleep 5"),
|
|
346
|
+
)
|
|
347
|
+
comprueba(
|
|
348
|
+
"· and reads back verbatim, flags included",
|
|
349
|
+
_c2.campo(open(fmix).read(), "runs.dbt"),
|
|
350
|
+
"echo OTRO_AGENTE && sleep 5",
|
|
351
|
+
)
|
|
352
|
+
afirma(
|
|
353
|
+
"· while claude stays the default for windows without it",
|
|
354
|
+
_c2.campo(open(fmix).read(), "runs.etl") == "",
|
|
355
|
+
)
|
|
356
|
+
afirma(
|
|
357
|
+
"· and the session script wires it in",
|
|
358
|
+
"runs.$win" in open(os.path.join(RAIZ, "plugin", "scripts", "city-session.sh")).read(),
|
|
359
|
+
)
|
|
360
|
+
shutil.rmtree(mixto)
|
|
361
|
+
|
|
362
|
+
# ── repo names that break things ───────────────────────────────────────
|
|
363
|
+
for crudo, limpio in (
|
|
364
|
+
("My_Repo", "my-repo"),
|
|
365
|
+
("UPPER", "upper"),
|
|
366
|
+
("with.dots", "with.dots"),
|
|
367
|
+
("a-b-c", "a-b-c"),
|
|
368
|
+
):
|
|
369
|
+
salida = subprocess.run(
|
|
370
|
+
["/bin/sh", "-c", f"echo '{crudo}' | tr '[:upper:]' '[:lower:]' | tr '_@/' '---'"],
|
|
371
|
+
capture_output=True,
|
|
372
|
+
text=True,
|
|
373
|
+
).stdout.strip()
|
|
374
|
+
comprueba(f"· the window name for {crudo}", salida, limpio)
|
|
375
|
+
|
|
376
|
+
# ── a real data repo must not be trampled ──────────────────────────────
|
|
377
|
+
propio = os.path.join(casa, "agents-city-data")
|
|
378
|
+
os.makedirs(propio)
|
|
379
|
+
mio = "# MY OWN MODELLING, six months of it\nunits:\n - id: banking ; name: Banking\n"
|
|
380
|
+
open(os.path.join(propio, "units.yml"), "w").write(mio)
|
|
381
|
+
S.escribe_suelo(propio, ["api"])
|
|
382
|
+
comprueba(
|
|
383
|
+
"· an existing units.yml is left exactly as it was",
|
|
384
|
+
open(os.path.join(propio, "units.yml")).read(),
|
|
385
|
+
mio,
|
|
386
|
+
)
|
|
387
|
+
afirma(
|
|
388
|
+
"· but the files that were missing do get written",
|
|
389
|
+
os.path.exists(os.path.join(propio, "parcels.yml")),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# And the caller must prefer that repo over our own folder.
|
|
393
|
+
with finge(os.environ if False else S, sh=con_git("x@y.com")):
|
|
394
|
+
os.environ["AGENTS_CITY_DATA"] = propio
|
|
395
|
+
comprueba(
|
|
396
|
+
"· a data repo in the environment wins over ~/.agents-city",
|
|
397
|
+
S.donde_viven_las_fichas(),
|
|
398
|
+
os.path.realpath(propio),
|
|
399
|
+
)
|
|
400
|
+
del os.environ["AGENTS_CITY_DATA"]
|
|
401
|
+
os.environ["AGENTS_CITY_DATA"] = os.path.join(casa, "does-not-exist")
|
|
402
|
+
afirma(
|
|
403
|
+
"· and a path that is not there is ignored rather than trusted",
|
|
404
|
+
S.donde_viven_las_fichas() != os.path.join(casa, "does-not-exist"),
|
|
405
|
+
)
|
|
406
|
+
del os.environ["AGENTS_CITY_DATA"]
|
|
407
|
+
|
|
408
|
+
# The plugin's own resolver must land where the seat writes, or /city:team
|
|
409
|
+
# looks in a folder that does not exist while the city sits one door over.
|
|
410
|
+
import city_env
|
|
411
|
+
|
|
412
|
+
for k in ("AGENTS_CITY_DATA", "CITY_DIR"):
|
|
413
|
+
os.environ.pop(k, None)
|
|
414
|
+
esperado = os.path.realpath(
|
|
415
|
+
os.path.join(os.path.expanduser("~/.agents-city"), cities.usuario_actual(), "home")
|
|
416
|
+
)
|
|
417
|
+
comprueba(
|
|
418
|
+
"· with nothing configured, the plugin resolves to user/home", city_env.datos(), esperado
|
|
419
|
+
)
|
|
420
|
+
explicito = tempfile.mkdtemp()
|
|
421
|
+
os.environ["AGENTS_CITY_DATA"] = explicito
|
|
422
|
+
comprueba("· and an explicit setting beats every default", city_env.datos(), explicito)
|
|
423
|
+
del os.environ["AGENTS_CITY_DATA"]
|
|
424
|
+
shutil.rmtree(explicito)
|
|
425
|
+
|
|
426
|
+
shutil.rmtree(casa)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ══ this machine cannot do it ══════════════════════════════════════════════
|
|
430
|
+
def maquinas_hostiles():
|
|
431
|
+
print(" machines that cannot")
|
|
432
|
+
|
|
433
|
+
# tmux missing and no package manager: must refuse, and say the command.
|
|
434
|
+
with finge(S, hay=lambda p: False):
|
|
435
|
+
import io
|
|
436
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
437
|
+
|
|
438
|
+
salida, err = io.StringIO(), io.StringIO()
|
|
439
|
+
with redirect_stdout(salida), redirect_stderr(err):
|
|
440
|
+
ok = S.asegura_tmux()
|
|
441
|
+
todo = salida.getvalue() + err.getvalue()
|
|
442
|
+
comprueba("· no tmux and no way to install it: refuses", ok, False)
|
|
443
|
+
afirma(
|
|
444
|
+
"· and prints the exact command per OS",
|
|
445
|
+
"brew install tmux" in todo and "apt-get install tmux" in todo,
|
|
446
|
+
todo[:200],
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# tmux already there: says nothing, does nothing.
|
|
450
|
+
with finge(S, hay=lambda p: p == "tmux"):
|
|
451
|
+
import io
|
|
452
|
+
from contextlib import redirect_stdout
|
|
453
|
+
|
|
454
|
+
salida = io.StringIO()
|
|
455
|
+
with redirect_stdout(salida):
|
|
456
|
+
ok = S.asegura_tmux()
|
|
457
|
+
comprueba("· tmux present: proceeds silently", (ok, salida.getvalue()), (True, ""))
|
|
458
|
+
|
|
459
|
+
# claude not on PATH: the plugin step must not be fatal.
|
|
460
|
+
with finge(S, hay=lambda p: p != "claude"):
|
|
461
|
+
import io
|
|
462
|
+
from contextlib import redirect_stdout
|
|
463
|
+
|
|
464
|
+
salida = io.StringIO()
|
|
465
|
+
with redirect_stdout(salida):
|
|
466
|
+
S.asegura_plugin("ana", "ana/lead", "/tmp/x")
|
|
467
|
+
afirma(
|
|
468
|
+
"· no claude on PATH: says so and carries on",
|
|
469
|
+
"skipping the plugin" in salida.getvalue(),
|
|
470
|
+
salida.getvalue()[:120],
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Already installed: must not reinstall, must not print noise.
|
|
474
|
+
with finge(
|
|
475
|
+
S,
|
|
476
|
+
hay=lambda p: True,
|
|
477
|
+
sh=lambda a, **k: ("city@agents-city enabled" if a[:2] == ["claude", "plugin"] else ""),
|
|
478
|
+
):
|
|
479
|
+
import io
|
|
480
|
+
from contextlib import redirect_stdout
|
|
481
|
+
|
|
482
|
+
salida = io.StringIO()
|
|
483
|
+
with redirect_stdout(salida):
|
|
484
|
+
S.asegura_plugin("ana", "ana/lead", "/tmp/x")
|
|
485
|
+
comprueba("· plugin already installed: no-op", salida.getvalue(), "")
|
|
486
|
+
|
|
487
|
+
# Installed does not mean current. npm may have replaced the package while
|
|
488
|
+
# Claude still points at an older cached plugin version.
|
|
489
|
+
llamadas = []
|
|
490
|
+
|
|
491
|
+
class SubprocessPlugin:
|
|
492
|
+
@staticmethod
|
|
493
|
+
def run(args, **_kw):
|
|
494
|
+
llamadas.append(args)
|
|
495
|
+
return type("R", (), {"returncode": 0, "stdout": "updated", "stderr": ""})()
|
|
496
|
+
|
|
497
|
+
listado_viejo = "Installed plugins:\n\n ❯ city@agents-city\n Version: 1.2.15\n"
|
|
498
|
+
with (
|
|
499
|
+
finge(S, hay=lambda p: True, sh=lambda a, **k: listado_viejo),
|
|
500
|
+
finge(S, subprocess=SubprocessPlugin),
|
|
501
|
+
):
|
|
502
|
+
import io
|
|
503
|
+
from contextlib import redirect_stdout
|
|
504
|
+
|
|
505
|
+
salida = io.StringIO()
|
|
506
|
+
with redirect_stdout(salida):
|
|
507
|
+
S.asegura_plugin("ana", "ana/lead", "/tmp/x")
|
|
508
|
+
afirma(
|
|
509
|
+
"· an old cached Claude plugin is updated through Claude's own CLI",
|
|
510
|
+
llamadas
|
|
511
|
+
and llamadas[0][:4] == ["claude", "plugin", "update", "city@agents-city"]
|
|
512
|
+
and "0.3.0-beta.21" in salida.getvalue(),
|
|
513
|
+
f"calls={llamadas!r} output={salida.getvalue()!r}",
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Windows. tmux is not a thing there outside WSL, and city-session.sh is bash.
|
|
517
|
+
afirma(
|
|
518
|
+
"· this is a POSIX front door and the tests know it",
|
|
519
|
+
os.name == "posix",
|
|
520
|
+
"On Windows this needs WSL: tmux and bash are both required. "
|
|
521
|
+
"That belongs in the README, not in a silent failure.",
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ══ the tmux options really taking ═════════════════════════════════════════
|
|
526
|
+
def opciones_tmux():
|
|
527
|
+
print(" tmux options")
|
|
528
|
+
if not shutil.which("tmux"):
|
|
529
|
+
print(" (tmux not here — skipped)")
|
|
530
|
+
return
|
|
531
|
+
guion = os.path.join(RAIZ, "plugin", "scripts", "city-session.sh")
|
|
532
|
+
cuerpo = subprocess.run(
|
|
533
|
+
["sed", "-n", "/^comodidades()/,/^}/p", guion], capture_output=True, text=True
|
|
534
|
+
).stdout
|
|
535
|
+
servidor = f"city-test-{os.getpid()}"
|
|
536
|
+
subprocess.run(["tmux", "-L", servidor, "new-session", "-d", "-s", "p"], capture_output=True)
|
|
537
|
+
subprocess.run(
|
|
538
|
+
["/bin/bash", "-c", cuerpo.replace("tmux ", f"tmux -L {servidor} ") + "\ncomodidades"],
|
|
539
|
+
capture_output=True,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def opt(nombre, ventana=False):
|
|
543
|
+
return subprocess.run(
|
|
544
|
+
["tmux", "-L", servidor, "show-options", "-gwv" if ventana else "-gv", nombre],
|
|
545
|
+
capture_output=True,
|
|
546
|
+
text=True,
|
|
547
|
+
).stdout.strip()
|
|
548
|
+
|
|
549
|
+
# Window options. Setting these with -g fails silently, which is exactly how
|
|
550
|
+
# half of this block shipped doing nothing.
|
|
551
|
+
for nombre, espera in (("monitor-bell", "on"), ("monitor-activity", "on")):
|
|
552
|
+
comprueba(f"· {nombre} (a window option) really took", opt(nombre, True), espera)
|
|
553
|
+
afirma(
|
|
554
|
+
"· the bell style is ours, not the default `reverse`",
|
|
555
|
+
"colour160" in opt("window-status-bell-style", True),
|
|
556
|
+
opt("window-status-bell-style", True),
|
|
557
|
+
)
|
|
558
|
+
# Session options.
|
|
559
|
+
for nombre, espera in (
|
|
560
|
+
("bell-action", "other"),
|
|
561
|
+
("visual-bell", "off"),
|
|
562
|
+
("mouse", "on"),
|
|
563
|
+
("base-index", "1"),
|
|
564
|
+
("status-interval", "5"),
|
|
565
|
+
):
|
|
566
|
+
comprueba(f"· {nombre} really took", opt(nombre), espera)
|
|
567
|
+
afirma(
|
|
568
|
+
"· the clock is tmux strftime and not a shelled-out date",
|
|
569
|
+
"%H:%M" in opt("status-right") and "#(" not in opt("status-right"),
|
|
570
|
+
opt("status-right"),
|
|
571
|
+
)
|
|
572
|
+
subprocess.run(["tmux", "-L", servidor, "kill-server"], capture_output=True)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ══ role, goal, and where the folders come from ════════════════════════════
|
|
576
|
+
def puesto_completo():
|
|
577
|
+
print(" the seat itself: role, goal, sources")
|
|
578
|
+
|
|
579
|
+
# Domain is a separate first decision; it filters the role list rather than
|
|
580
|
+
# presenting software responsibilities to a clinician or lawyer.
|
|
581
|
+
vistos_dom = {}
|
|
582
|
+
|
|
583
|
+
def falso_dominio(titulo, ops, *a, **kw):
|
|
584
|
+
vistos_dom["titulo"], vistos_dom["ops"] = titulo, [o[0] for o in ops]
|
|
585
|
+
return "healthcare"
|
|
586
|
+
|
|
587
|
+
with finge(ui, una=falso_dominio):
|
|
588
|
+
comprueba("· pregunta_dominio returns what was chosen", S.pregunta_dominio(), "healthcare")
|
|
589
|
+
for esperado in ("software", "healthcare", "legal", "finance", "marketing"):
|
|
590
|
+
afirma(
|
|
591
|
+
f"· domain catalogue offers {esperado}",
|
|
592
|
+
esperado in vistos_dom["ops"],
|
|
593
|
+
str(vistos_dom["ops"]),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# The role is asked of everybody, but only relevant roles are on offer.
|
|
597
|
+
vistos = {}
|
|
598
|
+
|
|
599
|
+
def falso_una(titulo, ops, *a, **kw):
|
|
600
|
+
vistos["titulo"], vistos["ops"] = titulo, [o[0] for o in ops]
|
|
601
|
+
return "cpto"
|
|
602
|
+
|
|
603
|
+
with finge(ui, una=falso_una):
|
|
604
|
+
comprueba("· pregunta_rol returns what was chosen", S.pregunta_rol(), "cpto")
|
|
605
|
+
afirma(
|
|
606
|
+
"· software offers exactly its domain roles, dev included",
|
|
607
|
+
set(vistos["ops"]) == {r for r, _ in S.domains.roles_de("software")},
|
|
608
|
+
str(vistos["ops"]),
|
|
609
|
+
)
|
|
610
|
+
afirma("· every domain offers a genuinely blank seat role", "blank" in vistos["ops"])
|
|
611
|
+
afirma(
|
|
612
|
+
"· and dev is not the first thing you land on",
|
|
613
|
+
vistos["ops"][0] != "dev",
|
|
614
|
+
str(vistos["ops"][:2]),
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
with finge(ui, una=lambda t, ops, *a, **kw: ops[0][0]):
|
|
618
|
+
comprueba(
|
|
619
|
+
"· healthcare starts with its clinical chair role",
|
|
620
|
+
S.pregunta_rol("healthcare"),
|
|
621
|
+
"clinical-director",
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# A repo agent's professional role is independent from the city domain and
|
|
625
|
+
# from its technical bus authority. A software-city repo may answer as SEO.
|
|
626
|
+
respuestas = iter(["another-domain", "marketing", "seo"])
|
|
627
|
+
vistas_agente = []
|
|
628
|
+
|
|
629
|
+
def rol_agente(titulo, ops, *a, **kw):
|
|
630
|
+
vistas_agente.append([o[0] for o in ops])
|
|
631
|
+
return next(respuestas)
|
|
632
|
+
|
|
633
|
+
with finge(ui, una=rol_agente):
|
|
634
|
+
comprueba(
|
|
635
|
+
"· a repo agent can deliberately take a cross-domain SEO role",
|
|
636
|
+
S.pregunta_rol_agente("portfolio", "software"),
|
|
637
|
+
"seo",
|
|
638
|
+
)
|
|
639
|
+
afirma(
|
|
640
|
+
"· that path first offers software roles, then marketing roles",
|
|
641
|
+
"po" in vistas_agente[0] and "seo" in vistas_agente[2],
|
|
642
|
+
str(vistas_agente),
|
|
643
|
+
)
|
|
644
|
+
with finge(ui, una=lambda t, ops, *a, **kw: ops[0][0]):
|
|
645
|
+
comprueba(
|
|
646
|
+
"· blank is the explicit default for an unprofiled repo agent",
|
|
647
|
+
S.pregunta_rol_agente("scratch", "software"),
|
|
648
|
+
"blank",
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Quitting either question must not silently pick one.
|
|
652
|
+
with finge(ui, una=lambda *a, **kw: None):
|
|
653
|
+
comprueba(
|
|
654
|
+
"· quitting the domain question propagates the quit",
|
|
655
|
+
S.pregunta_dominio("software"),
|
|
656
|
+
None,
|
|
657
|
+
)
|
|
658
|
+
comprueba(
|
|
659
|
+
"· quitting the role question propagates the quit",
|
|
660
|
+
S.pregunta_rol("software", "devops"),
|
|
661
|
+
None,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
# ── the goal, all the way out and back in ──────────────────────────────
|
|
665
|
+
casa = tempfile.mkdtemp()
|
|
666
|
+
meta = {
|
|
667
|
+
"user": "ana",
|
|
668
|
+
"title": "No parcel is left without an owner",
|
|
669
|
+
"signal": "parcels with an owner in .city.yml",
|
|
670
|
+
"command": "grep -lc owner: */.city.yml",
|
|
671
|
+
"baseline": "0 of 3",
|
|
672
|
+
"target": "3 of 3",
|
|
673
|
+
"by": "this quarter",
|
|
674
|
+
}
|
|
675
|
+
ficha = os.path.join(casa, "ana.md")
|
|
676
|
+
S.escribe_ficha(ficha, "ana", "cpto", ["api"], meta)
|
|
677
|
+
texto = open(ficha).read()
|
|
678
|
+
afirma("· a card with a goal says so in its frontmatter", "goals_defined: true" in texto)
|
|
679
|
+
vuelta = card.objetivo(texto, "ana")
|
|
680
|
+
for k in ("title", "signal", "command", "baseline", "target", "by"):
|
|
681
|
+
comprueba(f"· the goal survives the round trip: {k}", vuelta[k], meta[k])
|
|
682
|
+
|
|
683
|
+
# A goal with no command must come back as no command, not as the prose that
|
|
684
|
+
# stands in for one on the page.
|
|
685
|
+
sin = dict(meta, command="")
|
|
686
|
+
S.escribe_ficha(ficha, "ana", "cpto", ["api"], sin)
|
|
687
|
+
afirma(
|
|
688
|
+
'· "manual" is shown to a reader but is not read back as a command',
|
|
689
|
+
card.objetivo(open(ficha).read(), "ana")["command"] == "",
|
|
690
|
+
repr(card.objetivo(open(ficha).read(), "ana")["command"]),
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# No goal at all.
|
|
694
|
+
S.escribe_ficha(ficha, "ana", "cpto", ["api"], None)
|
|
695
|
+
texto = open(ficha).read()
|
|
696
|
+
afirma("· no goal says goals_defined: false", "goals_defined: false" in texto)
|
|
697
|
+
comprueba("· and reads back as no goal", card.objetivo(texto, "ana"), None)
|
|
698
|
+
|
|
699
|
+
# ── the two writers must agree on the shape ────────────────────────────
|
|
700
|
+
# A round reads whichever produced the card, so if these drift it reads one of
|
|
701
|
+
# them wrong and says nothing.
|
|
702
|
+
otro = os.path.join(casa, "wizard")
|
|
703
|
+
os.makedirs(otro + "/roles", exist_ok=True)
|
|
704
|
+
import setup as W
|
|
705
|
+
|
|
706
|
+
W.escribe(
|
|
707
|
+
{
|
|
708
|
+
"destino": otro,
|
|
709
|
+
"unidades": [{"id": "u", "name": "U", "color": "aabbcc"}],
|
|
710
|
+
"roles": ["cpto"],
|
|
711
|
+
"repos": [],
|
|
712
|
+
"gente": [{"user": "ana", "role": "cpto"}],
|
|
713
|
+
"org": "",
|
|
714
|
+
"rutas": {},
|
|
715
|
+
"kind": "product",
|
|
716
|
+
"grow_cmd": "",
|
|
717
|
+
"objetivo": meta,
|
|
718
|
+
}
|
|
719
|
+
)
|
|
720
|
+
del_wizard = open(os.path.join(otro, "ana.md")).read()
|
|
721
|
+
S.escribe_ficha(ficha, "ana", "cpto", [], meta)
|
|
722
|
+
del_seat = open(ficha).read()
|
|
723
|
+
for etiqueta in (
|
|
724
|
+
"### O1 — ",
|
|
725
|
+
"- **What**:",
|
|
726
|
+
"- **How it is measured**:",
|
|
727
|
+
"- **Measure**:",
|
|
728
|
+
"- **Baseline**:",
|
|
729
|
+
"- **Target**:",
|
|
730
|
+
"- **By when**:",
|
|
731
|
+
"- **State**:",
|
|
732
|
+
):
|
|
733
|
+
afirma(
|
|
734
|
+
f"· both writers emit {etiqueta.strip()}",
|
|
735
|
+
etiqueta in del_wizard and etiqueta in del_seat,
|
|
736
|
+
f"wizard={etiqueta in del_wizard} seat={etiqueta in del_seat}",
|
|
737
|
+
)
|
|
738
|
+
afirma(
|
|
739
|
+
"· and the wizard's card reads back through the seat's parser",
|
|
740
|
+
(card.objetivo(del_wizard, "ana") or {}).get("title") == meta["title"],
|
|
741
|
+
repr(card.objetivo(del_wizard, "ana")),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# ── where the folders come from ────────────────────────────────────────
|
|
745
|
+
with finge(ui, una=lambda t, ops, *a, **kw: ops[0][0]):
|
|
746
|
+
comprueba(
|
|
747
|
+
"· the disk is the first option, so enter picks no account", S.elige_fuente(), "disco"
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
locales = {"api": "/home/a/api"}
|
|
751
|
+
# gh.repos is the only thing here that talks to the network, so it is the only
|
|
752
|
+
# thing stubbed. Everything about marking cloned-vs-not is the real code.
|
|
753
|
+
import gh as _gh
|
|
754
|
+
|
|
755
|
+
with finge(_gh, repos=lambda o: [("api", "the api", "2026-01-01"), ("web", "", "2026-02-01")]):
|
|
756
|
+
filas = S.repos_de_github("acme", locales)
|
|
757
|
+
porNombre = {f[0]: f for f in filas}
|
|
758
|
+
comprueba(
|
|
759
|
+
"· a GitHub repo that is cloned here carries its path", porNombre["api"][1], "/home/a/api"
|
|
760
|
+
)
|
|
761
|
+
comprueba(
|
|
762
|
+
"· one that is not says so instead of looking the same",
|
|
763
|
+
(porNombre["web"][1], porNombre["web"][3]),
|
|
764
|
+
("", "not cloned here"),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
# Cloning nothing must not prompt, and must not touch the disk.
|
|
768
|
+
with finge(
|
|
769
|
+
ui,
|
|
770
|
+
pantalla=lambda *a, **kw: (_ for _ in ()).throw(
|
|
771
|
+
AssertionError("asked about cloning when there was nothing to clone")
|
|
772
|
+
),
|
|
773
|
+
):
|
|
774
|
+
comprueba("· nothing to clone: no question asked", S.clona("acme", [], casa), {})
|
|
775
|
+
|
|
776
|
+
# Declining the clone leaves the picks on the card and writes nothing.
|
|
777
|
+
with finge(ui, pantalla=lambda *a, **kw: False):
|
|
778
|
+
destino = os.path.join(casa, "nope")
|
|
779
|
+
comprueba("· declining the clone changes nothing", S.clona("acme", ["x"], destino), {})
|
|
780
|
+
afirma("· and does not create the folder", not os.path.exists(destino))
|
|
781
|
+
|
|
782
|
+
# gh missing: not connected, and asegura_gh gives up rather than hanging.
|
|
783
|
+
with (
|
|
784
|
+
finge(S, hay=lambda p: p != "gh", instala=lambda *a, **k: False),
|
|
785
|
+
finge(_gh, conectado=lambda: False),
|
|
786
|
+
):
|
|
787
|
+
comprueba("· gh missing means not connected", S.gh_conectado(), False)
|
|
788
|
+
import io
|
|
789
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
790
|
+
|
|
791
|
+
o, e = io.StringIO(), io.StringIO()
|
|
792
|
+
with redirect_stdout(o), redirect_stderr(e):
|
|
793
|
+
ok = S.asegura_gh()
|
|
794
|
+
comprueba("· and asegura_gh refuses instead of guessing", ok, False)
|
|
795
|
+
afirma(
|
|
796
|
+
"· saying how to install it and that the disk still works",
|
|
797
|
+
"brew install gh" in (o.getvalue() + e.getvalue())
|
|
798
|
+
and "this disk" in (o.getvalue() + e.getvalue()),
|
|
799
|
+
(o.getvalue() + e.getvalue())[:200],
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# Installed but logged out: selecting GitHub launches the explicit web/device
|
|
803
|
+
# OAuth path and then continues. It is not an npm dependency or a hidden token.
|
|
804
|
+
estado = {"logged": False, "oauth": 0}
|
|
805
|
+
|
|
806
|
+
def oauth_falso():
|
|
807
|
+
estado["oauth"] += 1
|
|
808
|
+
estado["logged"] = True
|
|
809
|
+
return True
|
|
810
|
+
|
|
811
|
+
with (
|
|
812
|
+
finge(S, hay=lambda _p: True, gh_conectado=lambda: estado["logged"]),
|
|
813
|
+
finge(_gh, autentica_web=oauth_falso),
|
|
814
|
+
):
|
|
815
|
+
import io
|
|
816
|
+
from contextlib import redirect_stdout
|
|
817
|
+
|
|
818
|
+
o = io.StringIO()
|
|
819
|
+
with redirect_stdout(o):
|
|
820
|
+
ok = S.asegura_gh()
|
|
821
|
+
afirma(
|
|
822
|
+
"· logged-out gh opens exactly one browser OAuth flow",
|
|
823
|
+
ok and estado["oauth"] == 1,
|
|
824
|
+
repr(estado),
|
|
825
|
+
)
|
|
826
|
+
afirma(
|
|
827
|
+
"· the user sees that it is OAuth with a device-code fallback",
|
|
828
|
+
"OAuth" in o.getvalue() and "device code" in o.getvalue(),
|
|
829
|
+
o.getvalue(),
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
shutil.rmtree(casa)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ══ the two doors reach the same code ══════════════════════════════════════
|
|
836
|
+
def dos_puertas():
|
|
837
|
+
print(" both doors, one implementation")
|
|
838
|
+
|
|
839
|
+
# `/city:join` runs inside Claude from an installed plugin: `plugin/` is copied to
|
|
840
|
+
# ~/.claude/plugins/cache/... and `bin/`, `city/` and `demo/` are not there at all.
|
|
841
|
+
# So the seat has to run with nothing around it but that one folder. It used to
|
|
842
|
+
# live in bin/ and import from bin/, which is why the command reimplemented the
|
|
843
|
+
# whole job in prose instead — and why the two versions drifted.
|
|
844
|
+
jaula = tempfile.mkdtemp()
|
|
845
|
+
copia = os.path.join(jaula, "instalado")
|
|
846
|
+
shutil.copytree(
|
|
847
|
+
os.path.join(RAIZ, "plugin"), copia, ignore=shutil.ignore_patterns("__pycache__")
|
|
848
|
+
)
|
|
849
|
+
afirma(
|
|
850
|
+
"· the isolated copy has no clone around it",
|
|
851
|
+
not any(
|
|
852
|
+
os.path.isdir(os.path.join(jaula, d)) for d in ("bin", "city", "demo", "templates")
|
|
853
|
+
),
|
|
854
|
+
)
|
|
855
|
+
afirma(
|
|
856
|
+
"· and no stale bytecode came with it",
|
|
857
|
+
not any("__pycache__" in r for r, _, _ in os.walk(copia)),
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
seat_aislado = os.path.join(copia, "scripts", "seat.py")
|
|
861
|
+
afirma("· and it ships the seat", os.path.isfile(seat_aislado))
|
|
862
|
+
for dep in ("card.py", "gh.py", "roles.py", "ui.py", "parcels.py"):
|
|
863
|
+
afirma(
|
|
864
|
+
f"· and {dep}, which it imports", os.path.isfile(os.path.join(copia, "scripts", dep))
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
# Run it from somewhere else entirely, so nothing resolves by luck of the cwd.
|
|
868
|
+
r = subprocess.run(
|
|
869
|
+
["python3", seat_aislado, "--help"], capture_output=True, text=True, cwd=jaula
|
|
870
|
+
)
|
|
871
|
+
comprueba("· it runs from an installed plugin with no clone", r.returncode, 0)
|
|
872
|
+
afirma(
|
|
873
|
+
"· without an import error",
|
|
874
|
+
"ModuleNotFoundError" not in r.stderr and "ImportError" not in r.stderr,
|
|
875
|
+
r.stderr[:300],
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
# And it can do the actual job there: write a card, with no bin/ in sight.
|
|
879
|
+
guion = os.path.join(jaula, "run.py")
|
|
880
|
+
open(guion, "w").write(f"""
|
|
881
|
+
import sys
|
|
882
|
+
sys.path.insert(0, {os.path.join(copia, "scripts")!r})
|
|
883
|
+
import ui
|
|
884
|
+
ui.una = lambda t, ops, *a, **k: 'cpto'
|
|
885
|
+
ui.elige = lambda t, ops, **k: []
|
|
886
|
+
ui.pantalla = lambda *a, **k: False
|
|
887
|
+
import importlib.machinery as m, importlib.util as u
|
|
888
|
+
s = u.spec_from_loader('seat', m.SourceFileLoader('seat', {seat_aislado!r}))
|
|
889
|
+
mod = u.module_from_spec(s); s.loader.exec_module(mod)
|
|
890
|
+
ficha = {os.path.join(jaula, "datos", "ana.md")!r}
|
|
891
|
+
mod.escribe_ficha(ficha, 'ana', 'cpto', [], None)
|
|
892
|
+
print(open(ficha).read())
|
|
893
|
+
""")
|
|
894
|
+
os.makedirs(os.path.join(jaula, "datos"), exist_ok=True)
|
|
895
|
+
r = subprocess.run(["python3", guion], capture_output=True, text=True, cwd=jaula)
|
|
896
|
+
afirma(
|
|
897
|
+
"· and writes a card from there", "agent: ana/lead" in r.stdout, (r.stdout + r.stderr)[:400]
|
|
898
|
+
)
|
|
899
|
+
afirma(
|
|
900
|
+
"· with the role the city calls an architect",
|
|
901
|
+
"the **architect**" in r.stdout,
|
|
902
|
+
r.stdout[:300],
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
# The launcher in bin/ must be a launcher, not a second copy.
|
|
906
|
+
lanzador = open(os.path.join(RAIZ, "bin", "seat")).read()
|
|
907
|
+
afirma(
|
|
908
|
+
"· bin/seat is a launcher over the shipped module",
|
|
909
|
+
"plugin/scripts" in lanzador and len(lanzador.splitlines()) < 15,
|
|
910
|
+
f"{len(lanzador.splitlines())} lines",
|
|
911
|
+
)
|
|
912
|
+
afirma("· and holds no logic of its own", "def " not in lanzador and "ui." not in lanzador)
|
|
913
|
+
|
|
914
|
+
# The picker nobody called is gone, and nothing refers to it.
|
|
915
|
+
afirma(
|
|
916
|
+
"· the dead repo picker is gone",
|
|
917
|
+
not os.path.exists(os.path.join(RAIZ, "plugin", "scripts", "pick-repos.py")),
|
|
918
|
+
)
|
|
919
|
+
# An *invocation*, not a mention: the places that could actually run it. A
|
|
920
|
+
# docstring recording that it existed is history, not a dangling reference.
|
|
921
|
+
donde = [os.path.join(RAIZ, "plugin", d) for d in ("commands", "skills", "hooks")]
|
|
922
|
+
donde += [os.path.join(RAIZ, "bin")]
|
|
923
|
+
r = subprocess.run(
|
|
924
|
+
["grep", "-rl", "--exclude-dir=__pycache__", "--exclude=test-*", "pick-repos", *donde],
|
|
925
|
+
capture_output=True,
|
|
926
|
+
text=True,
|
|
927
|
+
)
|
|
928
|
+
afirma("· and nothing invokes it any more", not r.stdout.strip(), r.stdout[:200])
|
|
929
|
+
# Bytecode of a module that no longer exists gets copied into an install like
|
|
930
|
+
# any other file, so an orphan .pyc is not just untidy.
|
|
931
|
+
huerfanos = [
|
|
932
|
+
os.path.join(d, f)
|
|
933
|
+
for d, _, fs in os.walk(RAIZ)
|
|
934
|
+
for f in fs
|
|
935
|
+
if f.endswith(".pyc")
|
|
936
|
+
and ".git" not in d
|
|
937
|
+
and not os.path.isfile(os.path.join(os.path.dirname(d), f.split(".cpython")[0] + ".py"))
|
|
938
|
+
]
|
|
939
|
+
afirma("· and no orphaned bytecode is left anywhere", not huerfanos, ", ".join(huerfanos[:3]))
|
|
940
|
+
|
|
941
|
+
# /city:join must delegate rather than describe the job again.
|
|
942
|
+
join = open(os.path.join(RAIZ, "plugin", "commands", "join.md")).read()
|
|
943
|
+
afirma("· /city:join runs the shipped seat", "scripts/seat.py" in join)
|
|
944
|
+
afirma(
|
|
945
|
+
"· and no longer respells the card by hand", "user, name, role, agent and repos" not in join
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
shutil.rmtree(jaula)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def motores_del_puesto():
|
|
952
|
+
"""The fifth question: what starts in each window. The happy answer is one
|
|
953
|
+
enter — nothing lands on the card, every window runs the person's Claude."""
|
|
954
|
+
print(" the engines question")
|
|
955
|
+
|
|
956
|
+
class Que:
|
|
957
|
+
def __init__(self, hay):
|
|
958
|
+
self.which = lambda m: f"/bin/{m}" if m in hay else None
|
|
959
|
+
|
|
960
|
+
with finge(ui, una=lambda *a, **k: "claude"):
|
|
961
|
+
comprueba(
|
|
962
|
+
"· enter writes nothing — every window stays on your Claude",
|
|
963
|
+
S.pregunta_motores(["api", "web"]),
|
|
964
|
+
{},
|
|
965
|
+
)
|
|
966
|
+
with finge(ui, una=lambda *a, **k: None):
|
|
967
|
+
comprueba("· quitting is a quit, not a silent default", S.pregunta_motores(["api"]), None)
|
|
968
|
+
with finge(ui, una=lambda *a, **k: "claude"):
|
|
969
|
+
comprueba(
|
|
970
|
+
"· owning no folders still leaves a seat to ask about", S.pregunta_motores([]), {}
|
|
971
|
+
)
|
|
972
|
+
with finge(ui, una=lambda *a, **k: "cpto"):
|
|
973
|
+
comprueba(
|
|
974
|
+
"· a scripted answer that is not a choice changes nothing",
|
|
975
|
+
S.pregunta_motores(["api"]),
|
|
976
|
+
{},
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
# Per window, seat first: it keeps Claude, the worktree goes to codex
|
|
980
|
+
# verbatim, the api window to Claude on haiku/low. Sorted order puts
|
|
981
|
+
# 'MiApp@feature/X' before 'api' (ASCII).
|
|
982
|
+
unas = iter(["elegir", "claude", "codex", "claude"])
|
|
983
|
+
pides = iter(["", "codex --full-auto", "haiku", "low"])
|
|
984
|
+
ofertas = []
|
|
985
|
+
|
|
986
|
+
def una(titulo, ops, *a, **k):
|
|
987
|
+
ofertas.append([o[0] for o in ops])
|
|
988
|
+
return next(unas)
|
|
989
|
+
|
|
990
|
+
with finge(ui, una=una, pide=lambda *a, **k: next(pides)), finge(
|
|
991
|
+
S, shutil=Que({"codex", "kimi"})
|
|
992
|
+
):
|
|
993
|
+
cambios = S.pregunta_motores(["MiApp@feature/X", "api"])
|
|
994
|
+
comprueba(
|
|
995
|
+
"· another CLI lands verbatim under the slugged window key",
|
|
996
|
+
{k: v for k, v in cambios.items() if "miapp" in k},
|
|
997
|
+
{
|
|
998
|
+
"runs.miapp-feature-x": "codex --full-auto",
|
|
999
|
+
"model.miapp-feature-x": "",
|
|
1000
|
+
"effort.miapp-feature-x": "",
|
|
1001
|
+
},
|
|
1002
|
+
)
|
|
1003
|
+
comprueba(
|
|
1004
|
+
"· a Claude window carries model and effort, and clears any runs key",
|
|
1005
|
+
{k: v for k, v in cambios.items() if "api" in k},
|
|
1006
|
+
{"runs.api": "", "model.api": "haiku", "effort.api": "low"},
|
|
1007
|
+
)
|
|
1008
|
+
afirma(
|
|
1009
|
+
"· the seat is asked about first — it is the window you sit in",
|
|
1010
|
+
len(ofertas) == 4 and "seat" not in str(ofertas[0]),
|
|
1011
|
+
str(ofertas[:2]),
|
|
1012
|
+
)
|
|
1013
|
+
afirma(
|
|
1014
|
+
"· only installed CLIs are offered, plus the verbatim door",
|
|
1015
|
+
ofertas[2] == ["claude", "codex", "kimi", "otro"],
|
|
1016
|
+
str(ofertas[2]),
|
|
1017
|
+
)
|
|
1018
|
+
comprueba(
|
|
1019
|
+
"· a seat left on Claude writes no runs key for itself",
|
|
1020
|
+
{k: v for k, v in cambios.items() if k.endswith(".seat")},
|
|
1021
|
+
{"runs.seat": "", "model.seat": "", "effort.seat": ""},
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
# The unhappy edges of one window's question.
|
|
1025
|
+
guion = {"r": iter([])}
|
|
1026
|
+
with (
|
|
1027
|
+
finge(ui, una=lambda *a, **k: next(guion["r"]), pide=lambda *a, **k: ""),
|
|
1028
|
+
finge(S, shutil=Que(set())),
|
|
1029
|
+
):
|
|
1030
|
+
# Second answer is the seat's: quit, so it stays exactly as it was and
|
|
1031
|
+
# these three stay about the repo window.
|
|
1032
|
+
guion["r"] = iter(["elegir", None, "otro"])
|
|
1033
|
+
comprueba(
|
|
1034
|
+
"· an empty command backs out instead of writing junk", S.pregunta_motores(["api"]), {}
|
|
1035
|
+
)
|
|
1036
|
+
guion["r"] = iter(["elegir", None, None])
|
|
1037
|
+
comprueba(
|
|
1038
|
+
"· quitting one window's question skips it untouched", S.pregunta_motores(["api"]), {}
|
|
1039
|
+
)
|
|
1040
|
+
guion["r"] = iter(["elegir", None, "claude"])
|
|
1041
|
+
comprueba(
|
|
1042
|
+
"· Claude with no model clears the keys back to the default",
|
|
1043
|
+
S.pregunta_motores(["api"]),
|
|
1044
|
+
{"runs.api": "", "model.api": "", "effort.api": ""},
|
|
1045
|
+
)
|
|
1046
|
+
guion["r"] = iter(["elegir", None, "otro"])
|
|
1047
|
+
with finge(ui, una=lambda *a, **k: next(guion["r"]), pide=lambda *a, **k: "gemini"):
|
|
1048
|
+
comprueba(
|
|
1049
|
+
"· an unknown CLI is an explicit terminal fallback",
|
|
1050
|
+
S.pregunta_motores(["api"]),
|
|
1051
|
+
{"runs.api": "terminal:gemini", "model.api": "", "effort.api": ""},
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def arranque_escalonado():
|
|
1056
|
+
"""Claude windows must not all start in the same millisecond.
|
|
1057
|
+
|
|
1058
|
+
Every Claude session on a machine shares one OAuth credential, and refreshing
|
|
1059
|
+
it rotates a single-use refresh token: the first process wins and the rest are
|
|
1060
|
+
left holding one the server already invalidated. It surfaces as "you have no
|
|
1061
|
+
quota" on an account with plenty, and only logging out and back in clears it
|
|
1062
|
+
(claude-code#24317, #25609, #27933, #48786). One window per repo made this
|
|
1063
|
+
product the worst possible caller.
|
|
1064
|
+
"""
|
|
1065
|
+
print(" claude windows start one at a time")
|
|
1066
|
+
|
|
1067
|
+
guion = os.path.join(RAIZ, "plugin", "scripts", "city-session.sh")
|
|
1068
|
+
trozo = subprocess.run(
|
|
1069
|
+
[
|
|
1070
|
+
"sed",
|
|
1071
|
+
"-n",
|
|
1072
|
+
"-e",
|
|
1073
|
+
"/^entero()/,/^}/p",
|
|
1074
|
+
"-e",
|
|
1075
|
+
"/^SETTLE=/p",
|
|
1076
|
+
"-e",
|
|
1077
|
+
"/^STAGGER=/p",
|
|
1078
|
+
"-e",
|
|
1079
|
+
"/^retraso()/,/^}/p",
|
|
1080
|
+
guion,
|
|
1081
|
+
],
|
|
1082
|
+
capture_output=True,
|
|
1083
|
+
text=True,
|
|
1084
|
+
).stdout
|
|
1085
|
+
afirma(
|
|
1086
|
+
"· the delay arithmetic is where this test expects it",
|
|
1087
|
+
"retraso()" in trozo and "SETTLE=" in trozo,
|
|
1088
|
+
trozo[:200],
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
sync_trozo = subprocess.run(
|
|
1092
|
+
["sed", "-n", "/^sync_line()/,/^}/p", guion],
|
|
1093
|
+
capture_output=True,
|
|
1094
|
+
text=True,
|
|
1095
|
+
).stdout
|
|
1096
|
+
no_repo = tempfile.mkdtemp(prefix="agents-city-non-git-")
|
|
1097
|
+
sync = subprocess.run(
|
|
1098
|
+
[
|
|
1099
|
+
"bash",
|
|
1100
|
+
"-c",
|
|
1101
|
+
f'DO_SYNC=1\n{sync_trozo}\ncd "$1"\neval "$(sync_line)"',
|
|
1102
|
+
"sync-test",
|
|
1103
|
+
no_repo,
|
|
1104
|
+
],
|
|
1105
|
+
capture_output=True,
|
|
1106
|
+
text=True,
|
|
1107
|
+
)
|
|
1108
|
+
shutil.rmtree(no_repo)
|
|
1109
|
+
afirma(
|
|
1110
|
+
"· sync quietly skips a city folder that is not a Git repository",
|
|
1111
|
+
sync.returncode == 0
|
|
1112
|
+
and "fatal:" not in sync.stdout
|
|
1113
|
+
and "fatal:" not in sync.stderr
|
|
1114
|
+
and "git rev-parse --is-inside-work-tree" in sync_trozo,
|
|
1115
|
+
f"stdout={sync.stdout!r} stderr={sync.stderr!r}",
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
def retraso(n, **entorno):
|
|
1119
|
+
return subprocess.run(
|
|
1120
|
+
["bash", "-c", f"{trozo}\nretraso {n}"],
|
|
1121
|
+
capture_output=True,
|
|
1122
|
+
text=True,
|
|
1123
|
+
env=dict(os.environ, **entorno),
|
|
1124
|
+
).stdout.strip()
|
|
1125
|
+
|
|
1126
|
+
comprueba("· the first repo window carries the whole settle", retraso(0), "8")
|
|
1127
|
+
comprueba("· the ones after it only space out", retraso(1), "9")
|
|
1128
|
+
comprueba("· the eighteenth is not a two-minute wait", retraso(17), "25")
|
|
1129
|
+
comprueba(
|
|
1130
|
+
"· zero opens everything at once, for whoever wants that",
|
|
1131
|
+
retraso(3, CITY_SETTLE="0", CITY_STAGGER="0"),
|
|
1132
|
+
"0",
|
|
1133
|
+
)
|
|
1134
|
+
comprueba("· both knobs are honoured", retraso(2, CITY_SETTLE="3", CITY_STAGGER="2"), "7")
|
|
1135
|
+
comprueba(
|
|
1136
|
+
"· a typo falls back to the default instead of crashing the day",
|
|
1137
|
+
retraso(0, CITY_SETTLE="ocho"),
|
|
1138
|
+
"8",
|
|
1139
|
+
)
|
|
1140
|
+
comprueba("· and so does an empty one", retraso(1, CITY_STAGGER=""), "9")
|
|
1141
|
+
|
|
1142
|
+
# And now the real script, with a tmux that writes down what it is told
|
|
1143
|
+
# instead of running it. `has-session` has to fail, or the script decides the
|
|
1144
|
+
# day is already open and attaches to it.
|
|
1145
|
+
casa = tempfile.mkdtemp()
|
|
1146
|
+
datos = os.path.join(casa, "datos")
|
|
1147
|
+
codigo = os.path.join(casa, "codigo")
|
|
1148
|
+
fbin = os.path.join(casa, "bin")
|
|
1149
|
+
for d in (datos, fbin, os.path.join(codigo, "api"), os.path.join(codigo, "docs")):
|
|
1150
|
+
os.makedirs(d)
|
|
1151
|
+
ficha = os.path.join(datos, "ana.md")
|
|
1152
|
+
S.escribe_ficha(
|
|
1153
|
+
ficha,
|
|
1154
|
+
"ana",
|
|
1155
|
+
"dev",
|
|
1156
|
+
["api", "docs"],
|
|
1157
|
+
repo_roles={"api": "data-engineer", "docs": "seo"},
|
|
1158
|
+
)
|
|
1159
|
+
card.pon_campo(ficha, "runs.docs", "codex --yolo")
|
|
1160
|
+
|
|
1161
|
+
registro = os.path.join(casa, "tmux.log")
|
|
1162
|
+
falso = os.path.join(fbin, "tmux")
|
|
1163
|
+
open(falso, "w").write(
|
|
1164
|
+
'#!/bin/bash\nprintf "%s\\n" "$*" >> '
|
|
1165
|
+
+ registro
|
|
1166
|
+
+ '\n[ "$1" = "has-session" ] && exit 1\nexit 0\n'
|
|
1167
|
+
)
|
|
1168
|
+
os.chmod(falso, 0o755)
|
|
1169
|
+
falso_node = os.path.join(fbin, "node")
|
|
1170
|
+
open(falso_node, "w").write(
|
|
1171
|
+
'#!/bin/bash\ncase "$*" in *runtime-dir*) echo "'
|
|
1172
|
+
+ os.path.join(casa, "runtime")
|
|
1173
|
+
+ '";; esac\nexit 0\n'
|
|
1174
|
+
)
|
|
1175
|
+
os.chmod(falso_node, 0o755)
|
|
1176
|
+
falso_claude = os.path.join(fbin, "claude")
|
|
1177
|
+
open(falso_claude, "w").write(
|
|
1178
|
+
'#!/bin/bash\n'
|
|
1179
|
+
'if [ "${1:-} ${2:-}" = "auth status" ]; then\n'
|
|
1180
|
+
' if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then\n'
|
|
1181
|
+
' echo \'{"loggedIn":true,"authMethod":"oauth_token"}\'\n'
|
|
1182
|
+
' else\n'
|
|
1183
|
+
' echo \'{"loggedIn":true,"authMethod":"claude.ai","subscriptionType":"team"}\'\n'
|
|
1184
|
+
' fi\n'
|
|
1185
|
+
'fi\n'
|
|
1186
|
+
'exit 0\n'
|
|
1187
|
+
)
|
|
1188
|
+
os.chmod(falso_claude, 0o755)
|
|
1189
|
+
stale_oauth = "STALE_TEST_TOKEN_MUST_NEVER_REACH_A_CHILD"
|
|
1190
|
+
|
|
1191
|
+
def corre(**extra):
|
|
1192
|
+
open(registro, "w").close()
|
|
1193
|
+
entorno = dict(os.environ)
|
|
1194
|
+
entorno.update({
|
|
1195
|
+
"HOME": casa,
|
|
1196
|
+
"AGENTS_CITY_DATA": datos,
|
|
1197
|
+
"CITY_CODE_DIR": codigo,
|
|
1198
|
+
"PATH": fbin + os.pathsep + os.environ["PATH"],
|
|
1199
|
+
"CLAUDE_CODE_OAUTH_TOKEN": stale_oauth,
|
|
1200
|
+
"CITY_CLAUDE_AUTH": "auto",
|
|
1201
|
+
})
|
|
1202
|
+
entorno.update(extra)
|
|
1203
|
+
subprocess.run(
|
|
1204
|
+
["bash", guion, "ana", "--claude", "--no-sync"],
|
|
1205
|
+
capture_output=True,
|
|
1206
|
+
text=True,
|
|
1207
|
+
cwd=casa,
|
|
1208
|
+
env=entorno,
|
|
1209
|
+
)
|
|
1210
|
+
# Production now gives tmux only a short private launcher path. Decode
|
|
1211
|
+
# that test-owned launcher to keep asserting the exact command contract
|
|
1212
|
+
# without regressing to giant simulated keystrokes.
|
|
1213
|
+
commands = []
|
|
1214
|
+
for line in open(registro).read().splitlines():
|
|
1215
|
+
if "send-keys" not in line or " -l -- " not in line:
|
|
1216
|
+
continue
|
|
1217
|
+
pieces = shlex.split(line)
|
|
1218
|
+
try:
|
|
1219
|
+
target = pieces[pieces.index("-t") + 1]
|
|
1220
|
+
launcher = pieces[-1]
|
|
1221
|
+
script = open(launcher, encoding="utf-8").read()
|
|
1222
|
+
assignment = next(row for row in script.splitlines()
|
|
1223
|
+
if row.startswith("COMMAND_B64="))
|
|
1224
|
+
encoded = shlex.split(assignment)[0].split("=", 1)[1]
|
|
1225
|
+
command = base64.b64decode(encoded).decode("utf-8")
|
|
1226
|
+
commands.append(f"send-keys -t {target} {command}")
|
|
1227
|
+
except (OSError, ValueError, IndexError, StopIteration):
|
|
1228
|
+
continue
|
|
1229
|
+
return commands
|
|
1230
|
+
|
|
1231
|
+
lineas = corre(CITY_SETTLE="8", CITY_STAGGER="1")
|
|
1232
|
+
afirma(
|
|
1233
|
+
"· the script really did send a command per window",
|
|
1234
|
+
len(lineas) >= 3,
|
|
1235
|
+
f"{len(lineas)} send-keys: {lineas}",
|
|
1236
|
+
)
|
|
1237
|
+
api = next((l for l in lineas if "CITY_BUS_ACTOR=api" in l), "")
|
|
1238
|
+
docs = next((l for l in lineas if "CITY_BUS_ACTOR=docs" in l), "")
|
|
1239
|
+
asiento = next((l for l in lineas if "CITY_BUS_ACTOR=seat" in l), "")
|
|
1240
|
+
afirma(
|
|
1241
|
+
"· the api window is sent a wait, and it comes before claude",
|
|
1242
|
+
"sleep 8; " in api
|
|
1243
|
+
and api.index("sleep") < api.index("claude")
|
|
1244
|
+
and "CITY_AGENT_ROLE=data-engineer" in api,
|
|
1245
|
+
api[:160],
|
|
1246
|
+
)
|
|
1247
|
+
afirma(
|
|
1248
|
+
"· the seat goes first and waits for nobody",
|
|
1249
|
+
bool(asiento) and "sleep" not in asiento,
|
|
1250
|
+
asiento[:160],
|
|
1251
|
+
)
|
|
1252
|
+
afirma(
|
|
1253
|
+
"· another vendor's window never waits — it is not in this race",
|
|
1254
|
+
bool(docs) and "sleep" not in docs and "CITY_AGENT_ROLE=seo" in docs,
|
|
1255
|
+
docs[:160],
|
|
1256
|
+
)
|
|
1257
|
+
afirma(
|
|
1258
|
+
"· every runtime carries its authenticated bus actor",
|
|
1259
|
+
"CITY_BUS_ACTOR=seat" in asiento
|
|
1260
|
+
and "CITY_BUS_ACTOR=api" in api
|
|
1261
|
+
and "CITY_BUS_ACTOR=docs" in docs,
|
|
1262
|
+
)
|
|
1263
|
+
afirma(
|
|
1264
|
+
"· repo windows receive neither remote road URL nor token",
|
|
1265
|
+
"CITY_BUS_URL= CITY_BUS_TOKEN=" in api and "CITY_BUS_URL= CITY_BUS_TOKEN=" in docs,
|
|
1266
|
+
)
|
|
1267
|
+
claude_contract = (asiento + "\n" + api).replace("\\", "")
|
|
1268
|
+
afirma(
|
|
1269
|
+
"· Claude native peer messaging is refused and its tools denied",
|
|
1270
|
+
claude_contract.count("crossSessionInbound") == 2
|
|
1271
|
+
and claude_contract.count("refuse") >= 2
|
|
1272
|
+
and claude_contract.count("SendMessage,ListAgents") == 2,
|
|
1273
|
+
claude_contract[:900],
|
|
1274
|
+
)
|
|
1275
|
+
afirma(
|
|
1276
|
+
"· Claude delivery uses its native gateway without Channels or an admin prompt",
|
|
1277
|
+
"city-runtime.sh gateway seat" in asiento
|
|
1278
|
+
and "city-runtime.sh gateway api" in api
|
|
1279
|
+
and "CITY_CLAUDE_CHANNEL=1" not in asiento
|
|
1280
|
+
and "CITY_CLAUDE_CHANNEL=1" not in api
|
|
1281
|
+
and "--channels" not in asiento
|
|
1282
|
+
and "--channels" not in api
|
|
1283
|
+
and "--dangerously-load-development-channels" not in asiento
|
|
1284
|
+
and "--dangerously-load-development-channels" not in api,
|
|
1285
|
+
(asiento + "\n" + api)[:500],
|
|
1286
|
+
)
|
|
1287
|
+
auth_unset = "env -u CLAUDE_CODE_OAUTH_TOKEN -u ANTHROPIC_API_KEY"
|
|
1288
|
+
afirma(
|
|
1289
|
+
"· a stale inherited OAuth token cannot override the healthy Team login",
|
|
1290
|
+
auth_unset in asiento
|
|
1291
|
+
and auth_unset in api
|
|
1292
|
+
and stale_oauth not in "\n".join(lineas),
|
|
1293
|
+
(asiento + "\n" + api)[:700],
|
|
1294
|
+
)
|
|
1295
|
+
afirma(
|
|
1296
|
+
"· a known non-Claude runtime starts its native gateway without fallback",
|
|
1297
|
+
"city-runtime.sh gateway docs" in docs
|
|
1298
|
+
and "fallback" not in docs
|
|
1299
|
+
and "adapter.js" not in docs,
|
|
1300
|
+
docs[:300],
|
|
1301
|
+
)
|
|
1302
|
+
afirma(
|
|
1303
|
+
"· Codex avoids an outer seatbelt so its MCPs can use one native sandbox",
|
|
1304
|
+
"sandbox-exec" not in docs and "CITY_OUTER_CAGE=1" not in docs,
|
|
1305
|
+
docs[:500],
|
|
1306
|
+
)
|
|
1307
|
+
afirma(
|
|
1308
|
+
"· and it does not consume a turn either, so no gap is wasted",
|
|
1309
|
+
"sleep 9" not in " ".join(lineas),
|
|
1310
|
+
" ".join(lineas)[:200],
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
lineas = corre(CITY_SETTLE="0", CITY_STAGGER="0")
|
|
1314
|
+
afirma(
|
|
1315
|
+
"· zeros really do open everything at once",
|
|
1316
|
+
lineas and not any("sleep" in l for l in lineas),
|
|
1317
|
+
str(lineas)[:200],
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
# An owner deliberately using API/environment auth can opt out. Agents City
|
|
1321
|
+
# preserves the inherited credential and never prints its value into the
|
|
1322
|
+
# launcher command or logs.
|
|
1323
|
+
environment_lines = corre(
|
|
1324
|
+
CITY_SETTLE="0", CITY_STAGGER="0", CITY_CLAUDE_AUTH="environment")
|
|
1325
|
+
environment_claude = "\n".join(
|
|
1326
|
+
line for line in environment_lines
|
|
1327
|
+
if "CITY_BUS_ACTOR=seat" in line or "CITY_BUS_ACTOR=api" in line)
|
|
1328
|
+
afirma(
|
|
1329
|
+
"· non-happy: explicit environment auth is preserved without leaking it",
|
|
1330
|
+
bool(environment_claude)
|
|
1331
|
+
and "env -u CLAUDE_CODE_OAUTH_TOKEN" not in environment_claude
|
|
1332
|
+
and stale_oauth not in environment_claude,
|
|
1333
|
+
environment_claude[:500],
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
# A city with no Claude in it: the seat runs another CLI too. Then nobody has
|
|
1337
|
+
# refreshed a token yet, so the first repo window is the first Claude and has
|
|
1338
|
+
# nothing to wait for — the settle would be a wait for an event that never
|
|
1339
|
+
# happens.
|
|
1340
|
+
card.pon_campo(ficha, "runs.seat", "codex")
|
|
1341
|
+
lineas = corre(CITY_SETTLE="8", CITY_STAGGER="1")
|
|
1342
|
+
asiento = next((l for l in lineas if ":seat" in l), "")
|
|
1343
|
+
api = next((l for l in lineas if "CITY_BUS_ACTOR=api" in l), "")
|
|
1344
|
+
afirma(
|
|
1345
|
+
"· the seat honours runs.seat instead of always launching claude",
|
|
1346
|
+
"codex" in asiento and "claude" not in asiento,
|
|
1347
|
+
asiento[:160],
|
|
1348
|
+
)
|
|
1349
|
+
afirma(
|
|
1350
|
+
"· and it keeps the identity that makes it a seat",
|
|
1351
|
+
"CITY_BUS_ACTOR=seat" in asiento,
|
|
1352
|
+
asiento[:160],
|
|
1353
|
+
)
|
|
1354
|
+
afirma(
|
|
1355
|
+
"· with no Claude ahead of it, the first repo window waits for nobody",
|
|
1356
|
+
bool(api) and "sleep" not in api,
|
|
1357
|
+
api[:160],
|
|
1358
|
+
)
|
|
1359
|
+
card.pon_campo(ficha, "runs.seat", "")
|
|
1360
|
+
lineas = corre(CITY_SETTLE="8", CITY_STAGGER="1")
|
|
1361
|
+
asiento = next((l for l in lineas if ":seat" in l), "")
|
|
1362
|
+
afirma(
|
|
1363
|
+
"· clearing the key puts Claude's native stream gateway back in the seat",
|
|
1364
|
+
"claude" in asiento
|
|
1365
|
+
and "city-runtime.sh gateway seat" in asiento
|
|
1366
|
+
and "--channels" not in asiento,
|
|
1367
|
+
asiento[:160],
|
|
1368
|
+
)
|
|
1369
|
+
shutil.rmtree(casa)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def main():
|
|
1373
|
+
print()
|
|
1374
|
+
camino_feliz()
|
|
1375
|
+
dos_puertas()
|
|
1376
|
+
puesto_completo()
|
|
1377
|
+
motores_del_puesto()
|
|
1378
|
+
arranque_escalonado()
|
|
1379
|
+
caminos_infelices()
|
|
1380
|
+
maquinas_hostiles()
|
|
1381
|
+
opciones_tmux()
|
|
1382
|
+
return resumen("seat")
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
if __name__ == "__main__":
|
|
1386
|
+
sys.exit(main())
|