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,1696 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sit down to work. One command, no account, no setup.
|
|
3
|
+
|
|
4
|
+
./bin/seat open the selected city (creates `home` first time)
|
|
5
|
+
./bin/seat --agents build the roster again, agent by agent (--repos too)
|
|
6
|
+
./bin/seat --goal set your goal again
|
|
7
|
+
./bin/seat --engines what runs in your own chair: a model, or another CLI
|
|
8
|
+
./bin/seat --domain software change the city's work domain
|
|
9
|
+
./bin/seat --role dev set your role without being asked
|
|
10
|
+
./bin/seat --city home open one specific city
|
|
11
|
+
./bin/seat --no-yolo repo windows with confirmations on
|
|
12
|
+
./bin/seat --only a,b just these agents this time
|
|
13
|
+
|
|
14
|
+
Seven questions the first time — the work domain, your chair role inside it, the
|
|
15
|
+
city's agents, one goal, what runs in your own chair, whether it asks permission,
|
|
16
|
+
and whether the city goes on your desktop.
|
|
17
|
+
|
|
18
|
+
The third question is the city itself, and it is a loop rather than a list: an
|
|
19
|
+
agent is asked for in full — its name, the kind of work it does, its role,
|
|
20
|
+
everything it works on, the engine and effort that run it, and the skills it
|
|
21
|
+
starts with — and then you are asked for another, until you say the city is
|
|
22
|
+
complete. An agent is not a repository: it may answer for three services and a
|
|
23
|
+
folder of documents at once, or for documents alone with no git anywhere.
|
|
24
|
+
|
|
25
|
+
Then a tmux session: one window per agent, each with its assigned operating role
|
|
26
|
+
and engine already running, and a `seat` window holding the chair role. Every
|
|
27
|
+
agent runtime receives chaired assignments through one local bus and never
|
|
28
|
+
addresses another agent runtime directly.
|
|
29
|
+
|
|
30
|
+
It installs what it needs rather than telling you to: tmux, the city plugin, and
|
|
31
|
+
the separate GitHub CLI `gh` **only if** you choose to read your folders from
|
|
32
|
+
GitHub. `gh` is not bundled inside the npm package. That last one is a
|
|
33
|
+
choice on purpose — reading your disk asks nobody for anything and needs no
|
|
34
|
+
account, so it is the default, and installing a GitHub client and opening a login
|
|
35
|
+
flow at everybody would take that away.
|
|
36
|
+
|
|
37
|
+
None of this needs Cloudflare or an account. A user owns several autonomous
|
|
38
|
+
cities under ``~/.agents-city/<user>/<city>``; each has one seat, one role and
|
|
39
|
+
goal inside one work domain, its support repo agents and explicit roads to other
|
|
40
|
+
cities. Skills remain
|
|
41
|
+
installed in their repos and are discovered, never copied into the city.
|
|
42
|
+
|
|
43
|
+
Requires tmux and bash, so on Windows run it inside WSL.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import re
|
|
50
|
+
import shutil
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
|
|
54
|
+
GUIONES = os.path.dirname(os.path.abspath(__file__))
|
|
55
|
+
sys.path.insert(0, GUIONES)
|
|
56
|
+
import card # noqa: E402 a card: read, written
|
|
57
|
+
import gh # noqa: E402 who you are, and what GitHub knows
|
|
58
|
+
import roles # noqa: E402 the catalogue and the bus suffixes
|
|
59
|
+
import domains # noqa: E402 work domain -> relevant role and knowledge packs
|
|
60
|
+
import ui # noqa: E402 the terminal widgets
|
|
61
|
+
import units # noqa: E402 the districts file, one writer
|
|
62
|
+
import cities # noqa: E402 which cities exist, and what each is called
|
|
63
|
+
import parcels # noqa: E402 the houses file, one writer
|
|
64
|
+
import workspace # noqa: E402 agents, their workspaces, mounts and skills
|
|
65
|
+
import atajos # noqa: E402 the city's door on the desktop
|
|
66
|
+
|
|
67
|
+
# Where the clone is, when there is one. The session script and the map live in it;
|
|
68
|
+
# neither is needed to open a seat, and a seat opened from an installed plugin has no
|
|
69
|
+
# clone at all.
|
|
70
|
+
RAIZ = os.path.dirname(os.path.dirname(GUIONES))
|
|
71
|
+
|
|
72
|
+
# Nothing about roles, identity or a card is decided in this file. It asks the
|
|
73
|
+
# questions; roles.py, gh.py and card.py hold the answers, and the wizard reads the
|
|
74
|
+
# same three. Every one of those started as a copy in here, and every copy drifted.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Running things and finding programs is gh.py's job; a private copy here is how
|
|
78
|
+
# the two doors drift. The long timeout matters: find-repos.sh's first crawl of a
|
|
79
|
+
# big home directory is slow, and killing it half-way looks like an empty disk.
|
|
80
|
+
def sh(args, **kw):
|
|
81
|
+
return gh.sh(args, timeout=kw.pop("timeout", 300), **kw)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
hay = gh.hay
|
|
85
|
+
|
|
86
|
+
# One progress contract for the whole first-run flow. Nested source/clone screens
|
|
87
|
+
# remain inside the folders step rather than pretending they are extra decisions.
|
|
88
|
+
PASOS = 7
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def instala(programa, formulas):
|
|
92
|
+
"""Install one thing with whatever package manager is here. `formulas` maps a
|
|
93
|
+
manager to the package name, because they disagree (`gh` vs `github-cli`)."""
|
|
94
|
+
for gestor, plantilla in (
|
|
95
|
+
("brew", ["brew", "install", "{}"]),
|
|
96
|
+
("apt-get", ["sudo", "apt-get", "install", "-y", "{}"]),
|
|
97
|
+
("dnf", ["sudo", "dnf", "install", "-y", "{}"]),
|
|
98
|
+
("pacman", ["sudo", "pacman", "-S", "--noconfirm", "{}"]),
|
|
99
|
+
):
|
|
100
|
+
if gestor in formulas and hay(gestor):
|
|
101
|
+
orden = [p.format(formulas[gestor]) for p in plantilla]
|
|
102
|
+
print(f" Installing {programa}: {' '.join(orden)}\n")
|
|
103
|
+
subprocess.run(orden)
|
|
104
|
+
return hay(programa)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def asegura_tmux():
|
|
109
|
+
"""tmux, installed if it is not there.
|
|
110
|
+
|
|
111
|
+
The whole promise is one command, and "first go and install tmux" is a second
|
|
112
|
+
one. So this installs it with whatever package manager the machine has, says so
|
|
113
|
+
out loud, and if there is none it prints the exact line to run rather than a
|
|
114
|
+
guess about what went wrong.
|
|
115
|
+
"""
|
|
116
|
+
if hay("tmux"):
|
|
117
|
+
return True
|
|
118
|
+
print(" tmux is not here, and the session is made of tmux windows.")
|
|
119
|
+
if instala("tmux", {"brew": "tmux", "apt-get": "tmux", "dnf": "tmux", "pacman": "tmux"}):
|
|
120
|
+
print("\n tmux installed.\n")
|
|
121
|
+
return True
|
|
122
|
+
print(
|
|
123
|
+
"\n I could not install it. Install tmux and run this again:\n"
|
|
124
|
+
" macOS: brew install tmux\n"
|
|
125
|
+
" Debian: sudo apt-get install tmux\n",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def gh_conectado():
|
|
132
|
+
"""Whether `gh` is here AND logged in. Both, because an installed-but-anonymous
|
|
133
|
+
gh fails on the first list with a message about scopes that reads like a bug."""
|
|
134
|
+
return gh.conectado()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def asegura_gh():
|
|
138
|
+
"""The GitHub CLI, installed and logged in — only ever called because somebody
|
|
139
|
+
chose to read their repos from GitHub.
|
|
140
|
+
|
|
141
|
+
Deliberately not part of the default path. "No account needed" is the thing that
|
|
142
|
+
makes a seat work on its own, and installing a GitHub client and opening a login
|
|
143
|
+
flow at everybody is the opposite of that. Reading from your disk asks nobody
|
|
144
|
+
for anything, so it stays the default and this stays a choice.
|
|
145
|
+
"""
|
|
146
|
+
if gh_conectado():
|
|
147
|
+
return True
|
|
148
|
+
if not hay("gh"):
|
|
149
|
+
print(
|
|
150
|
+
"\n GitHub access uses the separate `gh` system CLI. It is not bundled "
|
|
151
|
+
"inside the npm package, and it is not installed unless you choose "
|
|
152
|
+
"GitHub here."
|
|
153
|
+
)
|
|
154
|
+
if not instala("gh", {"brew": "gh", "apt-get": "gh", "dnf": "gh", "pacman": "github-cli"}):
|
|
155
|
+
print(
|
|
156
|
+
" I could not install it. Either install it —\n"
|
|
157
|
+
" macOS: brew install gh\n"
|
|
158
|
+
" Debian: sudo apt-get install gh\n"
|
|
159
|
+
" — or pick your repos from this disk instead.\n",
|
|
160
|
+
file=sys.stderr,
|
|
161
|
+
)
|
|
162
|
+
return False
|
|
163
|
+
if not gh_conectado():
|
|
164
|
+
print(
|
|
165
|
+
"\n Opening GitHub OAuth in your browser. The terminal also shows a one-time\n"
|
|
166
|
+
" device code, so the same flow works when the browser cannot open.\n"
|
|
167
|
+
)
|
|
168
|
+
# No capture: this one is a conversation, and swallowing its output would
|
|
169
|
+
# leave somebody staring at a frozen terminal waiting for a device code.
|
|
170
|
+
gh.autentica_web()
|
|
171
|
+
if not gh_conectado():
|
|
172
|
+
print("\n Still not logged in. Falling back to this disk.\n", file=sys.stderr)
|
|
173
|
+
return False
|
|
174
|
+
print()
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def asegura_plugin(usuario, agente, datos):
|
|
179
|
+
"""The city plugin, installed and pointed at your card.
|
|
180
|
+
|
|
181
|
+
Without it the seat window is a plain Claude session: no `/city:goals`, no
|
|
182
|
+
`/city:round`, no skills. With it, and with no bus deployed anywhere, it is
|
|
183
|
+
your role with its commands — which is exactly what level 2 should be.
|
|
184
|
+
|
|
185
|
+
The two bus fields stay empty on purpose. They are what makes you reachable by
|
|
186
|
+
somebody else's agent, and that is a different level; the plugin used to mark
|
|
187
|
+
them required, which meant a person working alone was nagged for credentials to
|
|
188
|
+
a bus that did not exist.
|
|
189
|
+
"""
|
|
190
|
+
if not hay("claude"):
|
|
191
|
+
print(" (claude is not on PATH, so I am skipping the plugin — the session still opens.)")
|
|
192
|
+
return
|
|
193
|
+
instalados = sh(["claude", "plugin", "list"])
|
|
194
|
+
if "city@agents-city" in instalados:
|
|
195
|
+
instalada = ""
|
|
196
|
+
m = re.search(r"city@agents-city.*?^\s*Version:\s*(\S+)", instalados, re.M | re.S)
|
|
197
|
+
if m:
|
|
198
|
+
instalada = m.group(1)
|
|
199
|
+
manifiestos = [
|
|
200
|
+
os.path.join(RAIZ, "plugin", ".claude-plugin", "plugin.json"),
|
|
201
|
+
os.path.join(os.path.dirname(GUIONES), ".claude-plugin", "plugin.json"),
|
|
202
|
+
]
|
|
203
|
+
deseada = ""
|
|
204
|
+
for manifiesto in manifiestos:
|
|
205
|
+
try:
|
|
206
|
+
deseada = str(json.load(open(manifiesto, encoding="utf-8")).get("version") or "")
|
|
207
|
+
except (OSError, ValueError, TypeError):
|
|
208
|
+
continue
|
|
209
|
+
if deseada:
|
|
210
|
+
break
|
|
211
|
+
# Older Claude versions did not print plugin versions. Silence remains
|
|
212
|
+
# safer than reinstalling on every seat launch when comparison is
|
|
213
|
+
# impossible. Current Claude updates only on an observed mismatch.
|
|
214
|
+
if not instalada or not deseada or instalada == deseada:
|
|
215
|
+
return
|
|
216
|
+
print(f" Updating the city plugin from {instalada} to {deseada}…")
|
|
217
|
+
actualiza = subprocess.run(
|
|
218
|
+
[
|
|
219
|
+
"claude",
|
|
220
|
+
"plugin",
|
|
221
|
+
"update",
|
|
222
|
+
"city@agents-city",
|
|
223
|
+
"--scope",
|
|
224
|
+
"user",
|
|
225
|
+
"--yes",
|
|
226
|
+
],
|
|
227
|
+
capture_output=True,
|
|
228
|
+
text=True,
|
|
229
|
+
)
|
|
230
|
+
if actualiza.returncode == 0:
|
|
231
|
+
print(" · updated; the new city windows will load this version")
|
|
232
|
+
else:
|
|
233
|
+
print(" · the existing plugin remains enabled; its update did not complete")
|
|
234
|
+
return
|
|
235
|
+
print(" Installing the city plugin, so your seat has its commands…")
|
|
236
|
+
if "agents-city" not in sh(["claude", "plugin", "marketplace", "list"]):
|
|
237
|
+
subprocess.run(
|
|
238
|
+
["claude", "plugin", "marketplace", "add", RAIZ], capture_output=True, text=True
|
|
239
|
+
)
|
|
240
|
+
# No --config here on purpose. It writes into the user's global settings.json,
|
|
241
|
+
# and it only ever runs on a fresh install — so a plugin somebody installed by
|
|
242
|
+
# hand first would never get configured, which is how this was wrong a moment
|
|
243
|
+
# ago. The session passes AGENTS_CITY_DATA and CITY_BUS_AGENT in the
|
|
244
|
+
# environment instead, and city_env.py reads the environment before anything
|
|
245
|
+
# else. Same answer, nothing written to a file you did not ask us to touch.
|
|
246
|
+
r = subprocess.run(
|
|
247
|
+
["claude", "plugin", "install", "city@agents-city", "-y"], capture_output=True, text=True
|
|
248
|
+
)
|
|
249
|
+
if "Successfully installed" in r.stdout or "already installed" in r.stdout:
|
|
250
|
+
print(
|
|
251
|
+
f" · installed. Your seat is {agente}, reading "
|
|
252
|
+
f"{datos.replace(os.path.expanduser('~'), '~')}"
|
|
253
|
+
)
|
|
254
|
+
print(" · no bus token needed — local roads work without one")
|
|
255
|
+
else:
|
|
256
|
+
print(" · could not install it; the session still opens without it.")
|
|
257
|
+
if r.stderr.strip():
|
|
258
|
+
print(f" {r.stderr.strip().splitlines()[0]}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def quien_soy():
|
|
262
|
+
"""Your git email, minus the domain. Wrong for maybe one person in twenty, and
|
|
263
|
+
for them the argument exists — which beats asking twenty people a question
|
|
264
|
+
nineteen already answered by configuring git.
|
|
265
|
+
|
|
266
|
+
The email-to-username rule lives in gh.py and is imported, not repeated. It was
|
|
267
|
+
repeated once, and the two copies disagreed: this one turned
|
|
268
|
+
`12345678+alice@…` into `12345678alice` while the wizard turned it into `alice`,
|
|
269
|
+
so the same person got two different cards depending on the door.
|
|
270
|
+
"""
|
|
271
|
+
if os.environ.get("AGENTS_CITY_USER"):
|
|
272
|
+
return gh.usuario_de_correo(os.environ["AGENTS_CITY_USER"])
|
|
273
|
+
correo = sh(["git", "config", "user.email"]).strip()
|
|
274
|
+
return gh.usuario_de_correo(correo) or os.environ.get("USER", "me")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def donde_viven_las_fichas(usuario=""):
|
|
278
|
+
"""The selected city, through the one resolver every door shares."""
|
|
279
|
+
return cities.actual(usuario or quien_soy())
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def repos_del_disco():
|
|
283
|
+
"""Every repo cloned on this machine, by the name on its remote.
|
|
284
|
+
|
|
285
|
+
find-repos.sh already does this and caches it for a day, which matters: the
|
|
286
|
+
first crawl of a full home directory is not fast, and nobody wants to wait for
|
|
287
|
+
it twice.
|
|
288
|
+
"""
|
|
289
|
+
salida = sh([os.path.join(GUIONES, "find-repos.sh")])
|
|
290
|
+
fuera = []
|
|
291
|
+
for linea in salida.splitlines():
|
|
292
|
+
if "\t" in linea:
|
|
293
|
+
nombre, ruta = linea.split("\t", 1)
|
|
294
|
+
fuera.append((nombre.strip(), ruta.strip()))
|
|
295
|
+
return fuera
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def ultimo_commit(ruta):
|
|
299
|
+
return sh(["git", "-C", ruta, "log", "-1", "--format=%ad", "--date=short"]).strip()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def mios(usuario, correo, repos):
|
|
303
|
+
"""Which of these you have actually committed to in the last year. The picker
|
|
304
|
+
starts with these ticked, so the common case is look-and-confirm rather than
|
|
305
|
+
hunting your own repos out of a list of two hundred."""
|
|
306
|
+
marcados = set()
|
|
307
|
+
for nombre, ruta in repos:
|
|
308
|
+
autores = sh(["git", "-C", ruta, "log", "--since=1.year", "--format=%ae"])
|
|
309
|
+
if any(
|
|
310
|
+
a.split("@")[0].strip().lower() in (usuario, correo.split("@")[0].lower())
|
|
311
|
+
for a in autores.splitlines()
|
|
312
|
+
if a.strip()
|
|
313
|
+
):
|
|
314
|
+
marcados.add(nombre)
|
|
315
|
+
return marcados
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def escribe_ficha(ruta, usuario, rol, agentes=None, objetivo=None, ciudad=""):
|
|
319
|
+
"""The card. Everything the seat, a round and the map read about a person.
|
|
320
|
+
|
|
321
|
+
One shape: a roster of agents. `repos:` remains a card format this product
|
|
322
|
+
READS forever — every old city keeps working — but nothing writes it any
|
|
323
|
+
more, because two writers of one fact is how the terminal and the web ended
|
|
324
|
+
up producing different cities for the same city. The keys come from
|
|
325
|
+
`workspace.claves_de_roster`, the same ones the Hall's endpoints write.
|
|
326
|
+
|
|
327
|
+
The goal block comes from card.py, which the wizard also uses — a round
|
|
328
|
+
reads whichever door produced the card and must not be able to tell.
|
|
329
|
+
"""
|
|
330
|
+
agente = roles.agente(usuario, rol, ciudad)
|
|
331
|
+
agentes = list(agentes or [])
|
|
332
|
+
identidad = [
|
|
333
|
+
f"{clave}: {valor}"
|
|
334
|
+
for clave, valor in workspace.claves_de_roster(agentes).items()
|
|
335
|
+
if valor or clave == "agents"
|
|
336
|
+
]
|
|
337
|
+
fm = [
|
|
338
|
+
"---",
|
|
339
|
+
f"user: {usuario}",
|
|
340
|
+
f"name: {usuario}",
|
|
341
|
+
f"role: {rol}",
|
|
342
|
+
f"agent: {agente}",
|
|
343
|
+
*identidad,
|
|
344
|
+
f"goals_defined: {'true' if objetivo else 'false'}",
|
|
345
|
+
"---",
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
oficio = roles.oficio(rol, os.path.dirname(ruta))
|
|
349
|
+
cuerpo = [
|
|
350
|
+
"",
|
|
351
|
+
f"# {usuario}",
|
|
352
|
+
"",
|
|
353
|
+
card.linea_rol(rol, oficio),
|
|
354
|
+
"",
|
|
355
|
+
"## Agents",
|
|
356
|
+
"",
|
|
357
|
+
]
|
|
358
|
+
if agentes:
|
|
359
|
+
for a in agentes:
|
|
360
|
+
monta = f"{len(a['mounts'])} mount{'s' if len(a['mounts']) != 1 else ''}"
|
|
361
|
+
cuerpo.append(f"- `{a['nombre']}` — {a['clase']}, role {a['rol']}, {monta}.")
|
|
362
|
+
cuerpo += ["", "Change the roster with `./bin/seat --agents`."]
|
|
363
|
+
else:
|
|
364
|
+
cuerpo += [
|
|
365
|
+
"No agents yet, on purpose. This role holds a property of "
|
|
366
|
+
"everybody else's — so it has no houses, and what it is for "
|
|
367
|
+
"begins where there is more than one city to reach.",
|
|
368
|
+
"",
|
|
369
|
+
"If that is wrong: `./bin/seat --agents`.",
|
|
370
|
+
]
|
|
371
|
+
cuerpo += ["", "## Current goals", ""]
|
|
372
|
+
|
|
373
|
+
cuerpo += card.bloque_objetivo(objetivo)
|
|
374
|
+
if objetivo:
|
|
375
|
+
cuerpo += ["_Set when this seat was opened._", ""]
|
|
376
|
+
|
|
377
|
+
cuerpo += ["## Round history", "", "Rounds leave their summary here, most recent first.", ""]
|
|
378
|
+
open(ruta, "w", encoding="utf-8").write("\n".join(fm + cuerpo))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def pregunta_dominio(actual=""):
|
|
382
|
+
"""The city's work context, before asking which responsibility the seat has."""
|
|
383
|
+
packs = domains.catalogo()
|
|
384
|
+
elegido = ui.una(
|
|
385
|
+
"What domain does this city work in?",
|
|
386
|
+
[(p["id"], p["name"], p["summary"]) for p in packs],
|
|
387
|
+
1,
|
|
388
|
+
PASOS,
|
|
389
|
+
ayuda="The domain chooses the vocabulary, evidence standard and relevant "
|
|
390
|
+
"roles. Its knowledge pack is copied into the city as editable "
|
|
391
|
+
"Markdown. Repo skills remain in their repos.",
|
|
392
|
+
)
|
|
393
|
+
return domains.canonico(elegido) if elegido else None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def pregunta_rol(dominio="software", actual=""):
|
|
397
|
+
"""Which role inside the selected domain. Asked of everybody, always.
|
|
398
|
+
|
|
399
|
+
It used to be asked only of somebody who picked no repos, and defaulted to `dev`
|
|
400
|
+
otherwise — which put every architect, surveyor and designer on the bus as
|
|
401
|
+
`<user>/dev`. The role is what the seat window *is*: it decides what reaches you
|
|
402
|
+
and the name you answer to. It is not a thing to infer.
|
|
403
|
+
"""
|
|
404
|
+
pack = domains.obtiene(dominio) or domains.obtiene("software")
|
|
405
|
+
catalogo = {r: (n, d, transversal) for r, n, d, transversal in roles.CATALOGO}
|
|
406
|
+
ops = []
|
|
407
|
+
for rol, _ in pack["roles"]:
|
|
408
|
+
nombre, descripcion, transversal = catalogo.get(
|
|
409
|
+
rol, (roles.nombre(rol), "", roles.transversal(rol))
|
|
410
|
+
)
|
|
411
|
+
if not transversal:
|
|
412
|
+
descripcion += " · owns workspaces and answers for them"
|
|
413
|
+
ops.append((rol, nombre, descripcion))
|
|
414
|
+
return ui.una(
|
|
415
|
+
f"Which role is yours in {pack['name']}?",
|
|
416
|
+
ops,
|
|
417
|
+
2,
|
|
418
|
+
PASOS,
|
|
419
|
+
ayuda="The domain says what kind of work this is. Your role says what the "
|
|
420
|
+
"owner seat is accountable for: what it asks support agents and what "
|
|
421
|
+
"must reach it. The seat remains the chair even if you choose blank. "
|
|
422
|
+
"Owning no folders is valid for a cross-cutting role.",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _opciones_rol(roles_pack, actual="", dominio_extra=""):
|
|
427
|
+
"""Picker rows for role ids, with the current choice first when present."""
|
|
428
|
+
catalogo = {r: (n, d) for r, n, d, _ in roles.CATALOGO}
|
|
429
|
+
ids = list(dict.fromkeys(r for r, _ in roles_pack))
|
|
430
|
+
if actual and actual not in ids:
|
|
431
|
+
ids.insert(0, actual)
|
|
432
|
+
elif actual in ids:
|
|
433
|
+
ids.remove(actual)
|
|
434
|
+
ids.insert(0, actual)
|
|
435
|
+
fuera = []
|
|
436
|
+
for rol in ids:
|
|
437
|
+
nombre, descripcion = catalogo.get(rol, (roles.nombre(rol), "custom role"))
|
|
438
|
+
sufijo = f" · {dominio_extra}" if dominio_extra else ""
|
|
439
|
+
if rol == actual:
|
|
440
|
+
sufijo += " · current"
|
|
441
|
+
fuera.append((rol, nombre, descripcion + sufijo))
|
|
442
|
+
return fuera
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def pregunta_rol_agente(repo, dominio, actual="blank"):
|
|
446
|
+
"""Choose one repo agent's operating role without confusing it with authority.
|
|
447
|
+
|
|
448
|
+
Roles from the city domain are one short list. ``another-domain`` opens the
|
|
449
|
+
other built-in packs, so a software city can deliberately give a portfolio
|
|
450
|
+
repo an SEO role. The seat remains the only chair either way.
|
|
451
|
+
"""
|
|
452
|
+
pack = domains.obtiene(dominio) or domains.obtiene("software")
|
|
453
|
+
actual = card.rol_seguro(actual)
|
|
454
|
+
ops = _opciones_rol(pack["roles"], actual)
|
|
455
|
+
ops.append(
|
|
456
|
+
(
|
|
457
|
+
"another-domain",
|
|
458
|
+
"Role from another domain…",
|
|
459
|
+
"marketing, finance, legal, medicine, research, sales or operations",
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
elegido = ui.una(
|
|
463
|
+
f"What role should the {repo} agent adopt?",
|
|
464
|
+
ops,
|
|
465
|
+
3,
|
|
466
|
+
PASOS,
|
|
467
|
+
ayuda="This is the repo agent's specialty, not authority. The city seat "
|
|
468
|
+
"remains its chair. Blank is an explicit role with no preset knowledge.",
|
|
469
|
+
)
|
|
470
|
+
if elegido is None:
|
|
471
|
+
return None
|
|
472
|
+
if elegido != "another-domain":
|
|
473
|
+
return elegido
|
|
474
|
+
|
|
475
|
+
packs = domains.catalogo()
|
|
476
|
+
otro = ui.una(
|
|
477
|
+
f"Which role family fits the {repo} agent?",
|
|
478
|
+
[(p["id"], p["name"], p["summary"]) for p in packs],
|
|
479
|
+
3,
|
|
480
|
+
PASOS,
|
|
481
|
+
ayuda="The repo stays in this city. This only chooses the specialist "
|
|
482
|
+
"vocabulary and role knowledge its agent adopts.",
|
|
483
|
+
)
|
|
484
|
+
if otro is None:
|
|
485
|
+
return None
|
|
486
|
+
elegido_pack = domains.obtiene(otro)
|
|
487
|
+
return ui.una(
|
|
488
|
+
f"What role should the {repo} agent adopt?",
|
|
489
|
+
_opciones_rol(elegido_pack["roles"], "", elegido_pack["name"]),
|
|
490
|
+
3,
|
|
491
|
+
PASOS,
|
|
492
|
+
ayuda="The seat remains the boss and the only router. This role controls "
|
|
493
|
+
"the perspective and evidence expected from this repo agent.",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def pregunta_roles_repos(repos, dominio, actuales=None):
|
|
498
|
+
"""One explicit operating role per concrete support agent."""
|
|
499
|
+
actuales = dict(actuales or {})
|
|
500
|
+
fuera = {}
|
|
501
|
+
for repo in sorted(repos):
|
|
502
|
+
elegido = pregunta_rol_agente(repo, dominio, actuales.get(repo, "blank"))
|
|
503
|
+
if elegido is None:
|
|
504
|
+
return None
|
|
505
|
+
fuera[repo] = elegido
|
|
506
|
+
return fuera
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def pregunta_objetivo(usuario):
|
|
510
|
+
"""One goal, with the command that measures it. Skippable, and it says so.
|
|
511
|
+
|
|
512
|
+
Not optional decoration: a round with no goal degrades into a status report,
|
|
513
|
+
because there is nothing to contrast the work against. But refusing to open a
|
|
514
|
+
seat until somebody has invented a metric is worse, so an empty title skips it.
|
|
515
|
+
"""
|
|
516
|
+
if not ui.pantalla(
|
|
517
|
+
"One goal, and the command that measures it",
|
|
518
|
+
"This is the part that does not change. Whatever your work is made of, "
|
|
519
|
+
"what holds it together is one goal, with the command that measures it.\n\n"
|
|
520
|
+
'A goal you cannot measure is not a goal here. "Improve performance" '
|
|
521
|
+
'cannot be accepted or refused with any judgement; "the p95 drops from '
|
|
522
|
+
'800 ms to 400 ms" can. And the measure is written, not described: if '
|
|
523
|
+
"the signal can be pulled with a command, that command is part of the "
|
|
524
|
+
"goal.\n\n"
|
|
525
|
+
"Skip it and your seat still opens. It just cannot tell you whether "
|
|
526
|
+
"anything is going well.",
|
|
527
|
+
4,
|
|
528
|
+
PASOS,
|
|
529
|
+
"enter to set one · q to skip",
|
|
530
|
+
):
|
|
531
|
+
return None
|
|
532
|
+
titulo = ui.pide(
|
|
533
|
+
"The goal, in one line",
|
|
534
|
+
"",
|
|
535
|
+
ayuda="What has to be achieved. Concrete enough to argue with. "
|
|
536
|
+
"Empty skips the goal entirely — set it any time with "
|
|
537
|
+
"./bin/seat --goal.",
|
|
538
|
+
)
|
|
539
|
+
if not titulo:
|
|
540
|
+
return None
|
|
541
|
+
# A measure is a command that returns a number, or a person's judgement written
|
|
542
|
+
# down. The second is a first-class answer, not a fallback: "quality of the
|
|
543
|
+
# AGENTS.md files, read by the architect on Fridays" is a real goal with no
|
|
544
|
+
# number anywhere. Every question from here on takes empty as "to be defined".
|
|
545
|
+
signal = (
|
|
546
|
+
ui.pide(
|
|
547
|
+
"How is it measured",
|
|
548
|
+
"",
|
|
549
|
+
ayuda="The signal that says whether it is going well. It does "
|
|
550
|
+
"not have to be a number — prose is a real answer. "
|
|
551
|
+
"Empty = to be defined.",
|
|
552
|
+
)
|
|
553
|
+
or ""
|
|
554
|
+
)
|
|
555
|
+
comando = (
|
|
556
|
+
ui.pide(
|
|
557
|
+
"The command that returns it, if a command can",
|
|
558
|
+
"",
|
|
559
|
+
ayuda="Runnable as written. Leave it EMPTY for a qualitative "
|
|
560
|
+
"goal — the next question asks who judges it instead.",
|
|
561
|
+
)
|
|
562
|
+
or ""
|
|
563
|
+
)
|
|
564
|
+
manual = ""
|
|
565
|
+
if not comando:
|
|
566
|
+
manual = (
|
|
567
|
+
ui.pide(
|
|
568
|
+
"Who judges it, and how often",
|
|
569
|
+
"",
|
|
570
|
+
ayuda='In prose: "the architect reads the AGENTS.md files '
|
|
571
|
+
'on Fridays". Empty = to be decided.',
|
|
572
|
+
)
|
|
573
|
+
or ""
|
|
574
|
+
)
|
|
575
|
+
return {
|
|
576
|
+
"user": usuario,
|
|
577
|
+
"title": titulo,
|
|
578
|
+
"signal": signal,
|
|
579
|
+
"command": comando,
|
|
580
|
+
"manual": manual,
|
|
581
|
+
"baseline": ui.pide(
|
|
582
|
+
"Where it stands today",
|
|
583
|
+
"",
|
|
584
|
+
ayuda="Run the command, or say it in words. Empty = not measured yet.",
|
|
585
|
+
)
|
|
586
|
+
or "",
|
|
587
|
+
"target": ui.pide("Where it has to get to", "", ayuda="Empty = to be defined.") or "",
|
|
588
|
+
"by": ui.pide("By when", "this quarter") or "this quarter",
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# The agent CLIs a window can run instead of Claude, offered when they are on the
|
|
593
|
+
# PATH. Unknown CLIs use the visibly labelled terminal compatibility fallback;
|
|
594
|
+
# a local model can still arrive through a native OpenCode command pointed at an
|
|
595
|
+
# OpenAI-compatible endpoint such as LM Studio or Ollama.
|
|
596
|
+
OTROS_MOTORES = ("codex", "opencode", "kimi")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def motor_de_ventana(win):
|
|
600
|
+
"""One window's engine. Returns the card keys to write for it — empty values
|
|
601
|
+
clear a key back to the default — or None when the question was quit."""
|
|
602
|
+
ops = [
|
|
603
|
+
(
|
|
604
|
+
"claude",
|
|
605
|
+
"Claude",
|
|
606
|
+
"the /city: commands live here"
|
|
607
|
+
if win == "seat"
|
|
608
|
+
else "your default, or pick a model next",
|
|
609
|
+
)
|
|
610
|
+
]
|
|
611
|
+
ops += [(m, m, "on this machine") for m in OTROS_MOTORES if shutil.which(m)]
|
|
612
|
+
ops.append(
|
|
613
|
+
(
|
|
614
|
+
"otro",
|
|
615
|
+
"another command (terminal fallback)",
|
|
616
|
+
"verbatim, flags included — explicit compatibility mode; "
|
|
617
|
+
"known runtimes never use terminal injection",
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
ayuda = (
|
|
621
|
+
"Claude reads the model from the card; any other CLI is launched "
|
|
622
|
+
"exactly as written, so its model goes in its own flags. Change it "
|
|
623
|
+
"any time: ./bin/seat --engines"
|
|
624
|
+
)
|
|
625
|
+
if win == "seat":
|
|
626
|
+
ayuda = (
|
|
627
|
+
"This is your own window, the one that holds your role. On Claude "
|
|
628
|
+
"it also carries the `/city:` commands and the plugin; on anything "
|
|
629
|
+
"else you keep your folder and your identity, and the same jobs "
|
|
630
|
+
"are done from the terminal (`agents-city seat --goal`, `--engines`, "
|
|
631
|
+
"`exit`). Pick another CLI here and the city has no Claude in it "
|
|
632
|
+
"at all — which is a real answer."
|
|
633
|
+
)
|
|
634
|
+
motor = ui.una(f"The `{win}` window runs…", ops, ayuda=ayuda)
|
|
635
|
+
if motor is None:
|
|
636
|
+
return None
|
|
637
|
+
if motor == "claude":
|
|
638
|
+
modelo = ui.pide("Model for this window (empty = your default)", "") or ""
|
|
639
|
+
esfuerzo = ""
|
|
640
|
+
if modelo:
|
|
641
|
+
esfuerzo = ui.pide("Effort (low…max, empty = default)", "") or ""
|
|
642
|
+
return {f"runs.{win}": "", f"model.{win}": modelo, f"effort.{win}": esfuerzo}
|
|
643
|
+
orden = (
|
|
644
|
+
ui.pide(
|
|
645
|
+
"The exact command",
|
|
646
|
+
"" if motor == "otro" else str(motor),
|
|
647
|
+
ayuda="Launched verbatim in the window, flags included.",
|
|
648
|
+
)
|
|
649
|
+
or ""
|
|
650
|
+
)
|
|
651
|
+
if not orden:
|
|
652
|
+
return {}
|
|
653
|
+
if motor == "otro" and not orden.startswith("terminal:"):
|
|
654
|
+
orden = "terminal:" + orden
|
|
655
|
+
return {f"runs.{win}": orden, f"model.{win}": "", f"effort.{win}": ""}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def pregunta_motores(elegidos):
|
|
659
|
+
"""What starts in each window. Enter keeps every window on the person's own
|
|
660
|
+
Claude default, which is the right silence — no keys land on the card.
|
|
661
|
+
|
|
662
|
+
Returns None on a quit, {} for "leave everything as it is", or card keys to
|
|
663
|
+
write. Choosing Claude for a window CLEARS its `runs.`/`model.`/`effort.`
|
|
664
|
+
keys, so coming back from codex to plain Claude is the same gesture as
|
|
665
|
+
leaving; quitting one window's question skips that window untouched.
|
|
666
|
+
|
|
667
|
+
The seat's own window is asked about first, and it is asked even when you own
|
|
668
|
+
no folders: there is always a seat, and a person who wants no Claude anywhere
|
|
669
|
+
has to be able to say so.
|
|
670
|
+
"""
|
|
671
|
+
eleccion = ui.una(
|
|
672
|
+
"What runs in each window?",
|
|
673
|
+
[
|
|
674
|
+
(
|
|
675
|
+
"claude",
|
|
676
|
+
"Claude, as it is now",
|
|
677
|
+
"every window starts your own Claude — nothing written",
|
|
678
|
+
),
|
|
679
|
+
(
|
|
680
|
+
"elegir",
|
|
681
|
+
"Pick per window",
|
|
682
|
+
"a model per window, or another CLI: Codex, OpenCode, Kimi, terminal fallback",
|
|
683
|
+
),
|
|
684
|
+
],
|
|
685
|
+
5,
|
|
686
|
+
PASOS,
|
|
687
|
+
ayuda="Each folder gets a window with an agent in it. This decides which "
|
|
688
|
+
"agent — and it is the skippable question: enter, and everything "
|
|
689
|
+
"runs your Claude.",
|
|
690
|
+
)
|
|
691
|
+
if eleccion is None:
|
|
692
|
+
return None
|
|
693
|
+
if eleccion != "elegir":
|
|
694
|
+
# 'claude' — and anything a script answers that is not a real choice.
|
|
695
|
+
return {}
|
|
696
|
+
cambios = {}
|
|
697
|
+
for win in ["seat"] + [card.ventana(r) for r in sorted(elegidos)]:
|
|
698
|
+
cambios.update(motor_de_ventana(win) or {})
|
|
699
|
+
return cambios
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def escribe_suelo(datos, repos, dominio="software"):
|
|
703
|
+
"""The least a map needs to draw: districts, and a house per repo.
|
|
704
|
+
|
|
705
|
+
Written only into our own folder and only when it is not there — a real data
|
|
706
|
+
repo's units.yml is somebody's modelling and this must never sit on top of it.
|
|
707
|
+
|
|
708
|
+
Why bother at all: "my city is my folders" should be true straight after
|
|
709
|
+
`./bin/seat`, with no wizard. The seeder degrades to an empty list when these
|
|
710
|
+
files are missing, and an empty list of districts is a map with no ground, so
|
|
711
|
+
the honest minimum is one district holding everything. Splitting repos into
|
|
712
|
+
parcels across real business units is the modelling nothing can do for you —
|
|
713
|
+
but it is an improvement on this, not a prerequisite for it.
|
|
714
|
+
"""
|
|
715
|
+
# No folders, no ground. Writing a district called "Mine" holding nothing is a
|
|
716
|
+
# lie about the shape of somebody's work, and under this model an empty city is
|
|
717
|
+
# a real answer rather than a half-finished one: a cross-cutting role lives on
|
|
718
|
+
# the roads between cities, and the roads are the bus.
|
|
719
|
+
# Domain belongs to the city even when it deliberately has no support repos.
|
|
720
|
+
# ``selecciona`` validates and writes only that scalar.
|
|
721
|
+
domains.selecciona(datos, dominio)
|
|
722
|
+
if not repos:
|
|
723
|
+
return []
|
|
724
|
+
|
|
725
|
+
hechos = []
|
|
726
|
+
u = os.path.join(datos, "units.yml")
|
|
727
|
+
if not os.path.exists(u):
|
|
728
|
+
# Written by the module that owns the format — the same one the hall's
|
|
729
|
+
# editor and the seeder read. This used to be a third inline copy.
|
|
730
|
+
units.escribe(u, [{"id": "mine", "name": "Mine", "color": "3fb8a0"}])
|
|
731
|
+
hechos.append('units.yml — one district, "Mine"')
|
|
732
|
+
|
|
733
|
+
p = os.path.join(datos, "parcels.yml")
|
|
734
|
+
if not os.path.exists(p):
|
|
735
|
+
parcels.escribe(p, {r: [{"ruta": "", "unidad": "mine", "nombre": r}] for r in repos})
|
|
736
|
+
hechos.append(
|
|
737
|
+
f"parcels.yml — {len(repos)} {'house' if len(repos) == 1 else 'houses'}, one per repo"
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
c = os.path.join(datos, "city.yml")
|
|
741
|
+
try:
|
|
742
|
+
texto = open(c, encoding="utf-8").read()
|
|
743
|
+
except OSError:
|
|
744
|
+
texto = ""
|
|
745
|
+
faltan = []
|
|
746
|
+
pack = domains.obtiene(dominio) or domains.obtiene("software")
|
|
747
|
+
if not card.campo("---\n" + texto + "---\n", "grow_command") and pack.get("grow_cmd"):
|
|
748
|
+
faltan.append(f"grow_command: {pack['grow_cmd']}")
|
|
749
|
+
if faltan:
|
|
750
|
+
if texto and not texto.endswith("\n"):
|
|
751
|
+
texto += "\n"
|
|
752
|
+
cities._atomico(c, texto + "\n".join(faltan) + "\n")
|
|
753
|
+
hechos.append("city.yml — domain-specific growth command")
|
|
754
|
+
return hechos
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def repos_de_ficha(ruta):
|
|
758
|
+
"""The folders on a card. Was a subprocess to read-card.py, which is the same
|
|
759
|
+
parser this now imports — one process instead of two, and one parser."""
|
|
760
|
+
return card.lee(ruta).get("repos", [])
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def elige_fuente():
|
|
764
|
+
"""Where the list of folders comes from. Disk first, and GitHub as a choice.
|
|
765
|
+
|
|
766
|
+
Order is the argument: reading your disk asks nobody for anything and works with
|
|
767
|
+
no account, so it is the default. GitHub is offered second because it is better
|
|
768
|
+
when your work is not all cloned yet — and picking it is what triggers installing
|
|
769
|
+
and logging in `gh`, rather than doing that to everybody up front.
|
|
770
|
+
"""
|
|
771
|
+
if gh_conectado():
|
|
772
|
+
github_estado = "GitHub CLI ready and authenticated"
|
|
773
|
+
elif hay("gh"):
|
|
774
|
+
github_estado = "GitHub CLI installed · opens browser OAuth"
|
|
775
|
+
else:
|
|
776
|
+
github_estado = "installs the separate GitHub CLI · opens browser OAuth"
|
|
777
|
+
return ui.una(
|
|
778
|
+
"Where should I look for your folders?",
|
|
779
|
+
[
|
|
780
|
+
(
|
|
781
|
+
"disco",
|
|
782
|
+
"On this machine",
|
|
783
|
+
"every repo already cloned · no account, nothing installed",
|
|
784
|
+
),
|
|
785
|
+
("mia", "My GitHub account", f"your own repos, cloned or not · {github_estado}"),
|
|
786
|
+
("org", "A GitHub organisation", f"work you share with other people · {github_estado}"),
|
|
787
|
+
],
|
|
788
|
+
3,
|
|
789
|
+
PASOS,
|
|
790
|
+
ayuda="You can come back and change this with ./bin/seat --repos. "
|
|
791
|
+
"Anything you pick that is not cloned here, I offer to clone.",
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def repos_de_github(dueno, locales):
|
|
796
|
+
"""A GitHub owner's repos, each marked with where it is on this disk or that it
|
|
797
|
+
is not here at all. A repo that is not cloned can still go on your card — it
|
|
798
|
+
just gets no window until it is, and the session says which ones it could not
|
|
799
|
+
find rather than quietly opening fewer windows."""
|
|
800
|
+
fuera = []
|
|
801
|
+
for nombre, descripcion, empujado in gh.repos(dueno):
|
|
802
|
+
ruta = locales.get(nombre, "")
|
|
803
|
+
fuera.append((nombre, ruta, empujado, descripcion or ("" if ruta else "not cloned here")))
|
|
804
|
+
return fuera
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def clona(dueno, faltan, destino):
|
|
808
|
+
"""Clone what somebody picked and does not have. Asked, never assumed: it is
|
|
809
|
+
network and disk in somebody else's home directory."""
|
|
810
|
+
if not faltan:
|
|
811
|
+
return {}
|
|
812
|
+
corto = destino.replace(os.path.expanduser("~"), "~")
|
|
813
|
+
if not ui.pantalla(
|
|
814
|
+
f"{len(faltan)} of those are not on this machine",
|
|
815
|
+
f"{', '.join(faltan[:8])}{' …' if len(faltan) > 8 else ''}\n\n"
|
|
816
|
+
f"They can stay on your card either way — a folder you do not have just "
|
|
817
|
+
f"gets no window, and the session tells you which ones it could not "
|
|
818
|
+
f"find.\n\n"
|
|
819
|
+
f"Or I clone them into {corto} now, and they all get one.",
|
|
820
|
+
3,
|
|
821
|
+
PASOS,
|
|
822
|
+
"enter to clone them · q to leave them on the card",
|
|
823
|
+
):
|
|
824
|
+
return {}
|
|
825
|
+
os.makedirs(destino, exist_ok=True)
|
|
826
|
+
puestas = {}
|
|
827
|
+
for nombre in faltan:
|
|
828
|
+
ruta = os.path.join(destino, nombre)
|
|
829
|
+
if os.path.isdir(ruta):
|
|
830
|
+
puestas[nombre] = ruta
|
|
831
|
+
continue
|
|
832
|
+
print(f" cloning {dueno}/{nombre}…")
|
|
833
|
+
r = subprocess.run(
|
|
834
|
+
["gh", "repo", "clone", f"{dueno}/{nombre}", ruta], capture_output=True, text=True
|
|
835
|
+
)
|
|
836
|
+
if os.path.isdir(ruta):
|
|
837
|
+
puestas[nombre] = ruta
|
|
838
|
+
else:
|
|
839
|
+
ultima = (r.stderr or "").strip().splitlines()
|
|
840
|
+
print(f" could not: {ultima[-1] if ultima else 'unknown reason'}")
|
|
841
|
+
# The index is cached for a day and has just gone stale by definition.
|
|
842
|
+
subprocess.run([os.path.join(GUIONES, "find-repos.sh"), "--refresh"], capture_output=True)
|
|
843
|
+
return puestas
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def resuelve_fuente():
|
|
847
|
+
"""Where the folder list comes from, GitHub resolved or fallen back from.
|
|
848
|
+
|
|
849
|
+
GitHub is a choice, and choosing it is what installs and logs in gh. If any of
|
|
850
|
+
that does not work out, fall back to the disk rather than dead-ending: the disk
|
|
851
|
+
always has an answer. Returns (fuente, dueno) or None on a quit."""
|
|
852
|
+
fuente = elige_fuente()
|
|
853
|
+
if fuente is None:
|
|
854
|
+
return None
|
|
855
|
+
dueno = ""
|
|
856
|
+
if fuente in ("mia", "org"):
|
|
857
|
+
if not asegura_gh():
|
|
858
|
+
fuente = "disco"
|
|
859
|
+
elif fuente == "mia":
|
|
860
|
+
dueno = sh(["gh", "api", "user", "--jq", ".login"]).strip()
|
|
861
|
+
if not dueno:
|
|
862
|
+
fuente = "disco"
|
|
863
|
+
else:
|
|
864
|
+
orgs = gh.orgs()
|
|
865
|
+
if not orgs:
|
|
866
|
+
ui.pantalla(
|
|
867
|
+
"No organisations",
|
|
868
|
+
"Your account does not belong to any organisation gh can "
|
|
869
|
+
"see, or the token is missing the read:org scope.\n\n"
|
|
870
|
+
"Reading from this disk instead.",
|
|
871
|
+
3,
|
|
872
|
+
PASOS,
|
|
873
|
+
)
|
|
874
|
+
fuente = "disco"
|
|
875
|
+
else:
|
|
876
|
+
dueno = ui.una("Which organisation?", [(o, o, "") for o in orgs], 3, PASOS)
|
|
877
|
+
if dueno is None:
|
|
878
|
+
return None
|
|
879
|
+
return fuente, dueno
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def elige_repos(usuario, ya_marcados):
|
|
883
|
+
"""Which folders are yours: where to read the list from, then which ones."""
|
|
884
|
+
locales = dict(repos_del_disco())
|
|
885
|
+
correo = sh(["git", "config", "user.email"]).strip()
|
|
886
|
+
|
|
887
|
+
r = resuelve_fuente()
|
|
888
|
+
if r is None:
|
|
889
|
+
return None
|
|
890
|
+
fuente, dueno = r
|
|
891
|
+
|
|
892
|
+
if fuente == "disco":
|
|
893
|
+
if not locales:
|
|
894
|
+
print(
|
|
895
|
+
"\n I found no git repos on this machine.\n"
|
|
896
|
+
" Clone something first, point the search somewhere else —\n"
|
|
897
|
+
" CITY_SEARCH_IN=/where/they/are ./bin/seat\n"
|
|
898
|
+
" — or run ./bin/seat --repos and read them from GitHub.\n",
|
|
899
|
+
file=sys.stderr,
|
|
900
|
+
)
|
|
901
|
+
return None
|
|
902
|
+
filas = [(n, r, ultimo_commit(r), "") for n, r in locales.items()]
|
|
903
|
+
else:
|
|
904
|
+
print(f" Asking GitHub for the repos of {dueno}…")
|
|
905
|
+
filas = repos_de_github(dueno, locales)
|
|
906
|
+
if not filas:
|
|
907
|
+
print(f"\n gh returned nothing for {dueno}.\n", file=sys.stderr)
|
|
908
|
+
return None
|
|
909
|
+
|
|
910
|
+
# Most recently touched first: the ones you are working in are the ones you are
|
|
911
|
+
# deciding about, and they should not be forty rows down.
|
|
912
|
+
filas.sort(key=lambda f: f[2] or "", reverse=True)
|
|
913
|
+
marcados = set(ya_marcados) or mios(usuario, correo, [(n, r) for n, r, _, _ in filas if r])
|
|
914
|
+
|
|
915
|
+
ops = []
|
|
916
|
+
for nombre, ruta, cuando, _ in filas:
|
|
917
|
+
donde = ruta.replace(os.path.expanduser("~"), "~") if ruta else "· not cloned here"
|
|
918
|
+
ops.append((nombre, nombre, f"{cuando or '—'} {donde}"))
|
|
919
|
+
|
|
920
|
+
elegidos = ui.elige(
|
|
921
|
+
f"Which folders do you answer for, {usuario}?",
|
|
922
|
+
ops,
|
|
923
|
+
marcadas=marcados,
|
|
924
|
+
minimo=0,
|
|
925
|
+
step=3,
|
|
926
|
+
of=PASOS,
|
|
927
|
+
ayuda="One window each, with an agent in it. Ticked are the ones you have "
|
|
928
|
+
"commits in this year. Pick what you would answer a question about — "
|
|
929
|
+
"nothing is a valid answer for a role that owns no folders.",
|
|
930
|
+
)
|
|
931
|
+
if elegidos is None:
|
|
932
|
+
return None
|
|
933
|
+
|
|
934
|
+
rutas = {n: r for n, r, _, _ in filas if r}
|
|
935
|
+
if dueno:
|
|
936
|
+
faltan = [n for n in elegidos if n not in rutas]
|
|
937
|
+
rutas.update(
|
|
938
|
+
clona(dueno, faltan, os.environ.get("CITY_CODE_DIR", os.path.expanduser("~/codigo")))
|
|
939
|
+
)
|
|
940
|
+
return elegidos, rutas
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def catalogo_de_repos(usuario):
|
|
944
|
+
"""The repo rows to offer, resolved ONCE for the whole roster.
|
|
945
|
+
|
|
946
|
+
The old wizard asked "which folders are yours?" a single time and made one
|
|
947
|
+
agent per tick. A roster asks per agent instead, and asking GitHub — or
|
|
948
|
+
re-walking the disk — once per agent would be both slow and rude. So the
|
|
949
|
+
source question happens once and every agent picks from the same rows.
|
|
950
|
+
|
|
951
|
+
Returns (rows, paths-by-name, github-owner) or None when the person quit.
|
|
952
|
+
"""
|
|
953
|
+
locales = dict(repos_del_disco())
|
|
954
|
+
correo = sh(["git", "config", "user.email"]).strip()
|
|
955
|
+
r = resuelve_fuente()
|
|
956
|
+
if r is None:
|
|
957
|
+
return None
|
|
958
|
+
fuente, dueno = r
|
|
959
|
+
if fuente == "disco":
|
|
960
|
+
filas = [(n, r, ultimo_commit(r), "") for n, r in locales.items()]
|
|
961
|
+
else:
|
|
962
|
+
print(f" Asking GitHub for the repos of {dueno}…")
|
|
963
|
+
filas = repos_de_github(dueno, locales) or []
|
|
964
|
+
filas.sort(key=lambda f: f[2] or "", reverse=True)
|
|
965
|
+
mios_ahora = mios(usuario, correo, [(n, r) for n, r, _, _ in filas if r])
|
|
966
|
+
return {
|
|
967
|
+
"filas": filas,
|
|
968
|
+
"rutas": {n: r for n, r, _, _ in filas if r},
|
|
969
|
+
"dueno": dueno,
|
|
970
|
+
"mios": mios_ahora,
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def pregunta_montajes(nombre, catalogo, sugeridos=()):
|
|
975
|
+
"""What one agent works on: any number of repos, plus any folders.
|
|
976
|
+
|
|
977
|
+
This is the shape the old wizard could not express. An agent is not "a
|
|
978
|
+
repo": it may answer for three services and a folder of documents, or for
|
|
979
|
+
documents alone with no git anywhere. Both are asked here, and none is
|
|
980
|
+
mandatory — an agent may start empty and mount later.
|
|
981
|
+
|
|
982
|
+
Returns the list of mount sources (paths, or repo names not cloned yet), or
|
|
983
|
+
None when the person quit.
|
|
984
|
+
"""
|
|
985
|
+
fuentes = []
|
|
986
|
+
filas = catalogo["filas"] if catalogo else []
|
|
987
|
+
if filas:
|
|
988
|
+
ops = []
|
|
989
|
+
for n, ruta, cuando, _ in filas:
|
|
990
|
+
donde = ruta.replace(os.path.expanduser("~"), "~") if ruta else "· not cloned here"
|
|
991
|
+
ops.append((n, n, f"{cuando or '—'} {donde}"))
|
|
992
|
+
elegidos = ui.elige(
|
|
993
|
+
f"Which repositories does {nombre} work on?",
|
|
994
|
+
ops,
|
|
995
|
+
marcadas=set(sugeridos),
|
|
996
|
+
minimo=0,
|
|
997
|
+
step=3,
|
|
998
|
+
of=PASOS,
|
|
999
|
+
ayuda="As many as this agent answers for — one, three, or none. "
|
|
1000
|
+
"They are mounted inside its workspace, not turned into separate "
|
|
1001
|
+
"agents. Nothing ticked is a valid answer.",
|
|
1002
|
+
)
|
|
1003
|
+
if elegidos is None:
|
|
1004
|
+
return None
|
|
1005
|
+
rutas_repo = dict(catalogo["rutas"])
|
|
1006
|
+
if catalogo["dueno"]:
|
|
1007
|
+
faltan = [n for n in elegidos if n not in rutas_repo]
|
|
1008
|
+
rutas_repo.update(
|
|
1009
|
+
clona(
|
|
1010
|
+
catalogo["dueno"],
|
|
1011
|
+
faltan,
|
|
1012
|
+
os.environ.get("CITY_CODE_DIR", os.path.expanduser("~/codigo")),
|
|
1013
|
+
)
|
|
1014
|
+
)
|
|
1015
|
+
fuentes += [rutas_repo.get(n, n) for n in sorted(elegidos)]
|
|
1016
|
+
|
|
1017
|
+
# And the work that is not a repo at all — the reason this model exists.
|
|
1018
|
+
while True:
|
|
1019
|
+
carpeta = ui.pide(
|
|
1020
|
+
f"A folder of documents for {nombre} (empty when done)",
|
|
1021
|
+
"",
|
|
1022
|
+
ayuda="Anything on this disk: a handbook, a drive folder, case "
|
|
1023
|
+
"files. It is mounted the same way a repo is, and needs no git.",
|
|
1024
|
+
)
|
|
1025
|
+
if carpeta is None:
|
|
1026
|
+
return None
|
|
1027
|
+
carpeta = carpeta.strip()
|
|
1028
|
+
if not carpeta:
|
|
1029
|
+
return fuentes
|
|
1030
|
+
destino = os.path.expanduser(carpeta)
|
|
1031
|
+
if not os.path.isdir(destino):
|
|
1032
|
+
print(f" ↳ there is no folder at {destino}")
|
|
1033
|
+
continue
|
|
1034
|
+
fuentes.append(destino)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def pregunta_clase(nombre):
|
|
1038
|
+
"""What kind of work this agent does — what makes its house grow."""
|
|
1039
|
+
return ui.una(
|
|
1040
|
+
f"What kind of work does {nombre} do?",
|
|
1041
|
+
[
|
|
1042
|
+
("code", "Code", "grows with merged pull requests; mounts repos and worktrees"),
|
|
1043
|
+
("knowledge", "Knowledge", "grows with documents; needs no git at all"),
|
|
1044
|
+
("coordinator", "Coordinator", "grows with recorded decisions"),
|
|
1045
|
+
],
|
|
1046
|
+
3,
|
|
1047
|
+
PASOS,
|
|
1048
|
+
ayuda="This is not a permission. It decides how this agent's work is "
|
|
1049
|
+
"counted and how its house grows on the map — shipping code, keeping "
|
|
1050
|
+
"knowledge, or deciding.",
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def pregunta_skills_de_agente(datos, nombre, slug, runtime):
|
|
1055
|
+
"""Offer to install skills into this agent's own home, for the engine that
|
|
1056
|
+
reads them.
|
|
1057
|
+
|
|
1058
|
+
Skills are the Claude runtime's format. Offering them for a window running
|
|
1059
|
+
Codex would be selling something that engine ignores, so the question is
|
|
1060
|
+
only asked where the answer means anything — and it says so.
|
|
1061
|
+
"""
|
|
1062
|
+
if runtime and runtime != "claude":
|
|
1063
|
+
print(f" {nombre} runs {runtime}, which ignores skills — none offered.")
|
|
1064
|
+
return []
|
|
1065
|
+
puestas = []
|
|
1066
|
+
while True:
|
|
1067
|
+
origen = ui.pide(
|
|
1068
|
+
f"A skill folder or .zip for {nombre} (empty when done)",
|
|
1069
|
+
"",
|
|
1070
|
+
ayuda="Installed into this agent's own home, and read by Claude in "
|
|
1071
|
+
"its window. Recognition elsewhere stays read-only: this is the one "
|
|
1072
|
+
"place Agents City writes a skill, because you asked it to.",
|
|
1073
|
+
)
|
|
1074
|
+
if origen is None or not str(origen).strip():
|
|
1075
|
+
return puestas
|
|
1076
|
+
puesta, mal = workspace.instala_skill(datos, slug, str(origen).strip())
|
|
1077
|
+
if mal:
|
|
1078
|
+
print(f" ↳ {mal}")
|
|
1079
|
+
continue
|
|
1080
|
+
puestas.append(puesta)
|
|
1081
|
+
print(f" ↳ {puesta} installed for {nombre}.")
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def pregunta_un_agente(datos, dominio, catalogo, ya_puestos):
|
|
1085
|
+
"""One agent, whole: who it is, what it does, what it works on, what runs
|
|
1086
|
+
it, and the skills it starts with. Returns the agent dict, or None on quit.
|
|
1087
|
+
"""
|
|
1088
|
+
def libre(valor):
|
|
1089
|
+
limpio = card.ventana(str(valor or "").strip())
|
|
1090
|
+
if not str(valor or "").strip():
|
|
1091
|
+
return "an agent needs a name"
|
|
1092
|
+
if limpio in {card.ventana(n) for n in ya_puestos}:
|
|
1093
|
+
return f"{limpio} is already an agent in this city"
|
|
1094
|
+
return ""
|
|
1095
|
+
|
|
1096
|
+
nombre = ui.pide(
|
|
1097
|
+
"The agent's name",
|
|
1098
|
+
"",
|
|
1099
|
+
validar=libre,
|
|
1100
|
+
ayuda="How you will call it — in its tmux window, on the map, and on "
|
|
1101
|
+
"the bus. A person's name, a service, a team: whatever you would say "
|
|
1102
|
+
"out loud when you ask it for something.",
|
|
1103
|
+
)
|
|
1104
|
+
if nombre is None or not nombre.strip():
|
|
1105
|
+
return None
|
|
1106
|
+
nombre = nombre.strip()
|
|
1107
|
+
slug = card.ventana(nombre)
|
|
1108
|
+
|
|
1109
|
+
clase = pregunta_clase(nombre)
|
|
1110
|
+
if clase is None:
|
|
1111
|
+
return None
|
|
1112
|
+
rol = pregunta_rol_agente(nombre, dominio)
|
|
1113
|
+
if rol is None:
|
|
1114
|
+
return None
|
|
1115
|
+
montajes = pregunta_montajes(nombre, catalogo)
|
|
1116
|
+
if montajes is None:
|
|
1117
|
+
return None
|
|
1118
|
+
# The engine, the model and the effort, asked where they belong: on the
|
|
1119
|
+
# agent that will run them. They used to be a separate late step about
|
|
1120
|
+
# "windows", which is the old repo-shaped vocabulary.
|
|
1121
|
+
motor = motor_de_ventana(slug)
|
|
1122
|
+
if motor is None:
|
|
1123
|
+
return None
|
|
1124
|
+
workspace.crea_workspace(datos, slug)
|
|
1125
|
+
skills = pregunta_skills_de_agente(datos, nombre, slug, motor.get(f"runs.{slug}", ""))
|
|
1126
|
+
return {
|
|
1127
|
+
"nombre": nombre,
|
|
1128
|
+
"slug": slug,
|
|
1129
|
+
"clase": clase,
|
|
1130
|
+
"rol": rol,
|
|
1131
|
+
"mounts": montajes,
|
|
1132
|
+
"motor": motor,
|
|
1133
|
+
"skills": skills,
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def pregunta_agentes(usuario, dominio, datos, actuales=()):
|
|
1138
|
+
"""The roster, built one agent at a time until the city is complete.
|
|
1139
|
+
|
|
1140
|
+
The city is not "your folders". It is the people in it, and each of them is
|
|
1141
|
+
asked for in full — name, kind, role, everything it works on, its engine and
|
|
1142
|
+
its skills — before the next one is offered. The loop ends when you say the
|
|
1143
|
+
city is complete, which is the only thing that ends it.
|
|
1144
|
+
"""
|
|
1145
|
+
agentes = list(actuales)
|
|
1146
|
+
catalogo = None
|
|
1147
|
+
while True:
|
|
1148
|
+
if agentes:
|
|
1149
|
+
hecho = ui.una(
|
|
1150
|
+
f"{len(agentes)} agent{'s' if len(agentes) != 1 else ''} so far: "
|
|
1151
|
+
+ ", ".join(a["nombre"] for a in agentes),
|
|
1152
|
+
[
|
|
1153
|
+
("otro", "Add another agent", "one more member of this city"),
|
|
1154
|
+
("fin", "That is the whole city", "the roster is complete"),
|
|
1155
|
+
],
|
|
1156
|
+
3,
|
|
1157
|
+
PASOS,
|
|
1158
|
+
ayuda="A city keeps working as you add to it — this is not your "
|
|
1159
|
+
"last chance. Change the roster any time with "
|
|
1160
|
+
"./bin/seat --agents.",
|
|
1161
|
+
)
|
|
1162
|
+
if hecho is None:
|
|
1163
|
+
return None
|
|
1164
|
+
if hecho != "otro":
|
|
1165
|
+
return agentes
|
|
1166
|
+
elif not ui.pantalla(
|
|
1167
|
+
"Now the people in this city",
|
|
1168
|
+
"A city is its agents, and each one is asked for in full: who it "
|
|
1169
|
+
"is, what kind of work it does, its role, everything it works on, "
|
|
1170
|
+
"the engine that runs it and the skills it starts with.\n\n"
|
|
1171
|
+
"An agent is not a repository. It can answer for three services "
|
|
1172
|
+
"and a folder of documents at once, or for documents alone with no "
|
|
1173
|
+
"git anywhere — that is why this asks agent by agent instead of "
|
|
1174
|
+
"handing you a list of folders to tick.\n\n"
|
|
1175
|
+
"You will be asked for another after each one, until you say the "
|
|
1176
|
+
"city is complete. None is a valid answer too: a seat with no "
|
|
1177
|
+
"agents is a city that reaches others over roads.",
|
|
1178
|
+
3,
|
|
1179
|
+
PASOS,
|
|
1180
|
+
"enter to add the first · q for a city with no agents yet",
|
|
1181
|
+
):
|
|
1182
|
+
return agentes
|
|
1183
|
+
if catalogo is None:
|
|
1184
|
+
catalogo = catalogo_de_repos(usuario)
|
|
1185
|
+
if catalogo is None:
|
|
1186
|
+
return None
|
|
1187
|
+
nuevo = pregunta_un_agente(datos, dominio, catalogo, [a["nombre"] for a in agentes])
|
|
1188
|
+
if nuevo is None:
|
|
1189
|
+
# Backing out of one agent leaves the roster as it was, rather than
|
|
1190
|
+
# throwing away everybody who was already described.
|
|
1191
|
+
if not agentes:
|
|
1192
|
+
return agentes
|
|
1193
|
+
continue
|
|
1194
|
+
agentes.append(nuevo)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def dominio_configurado(a, nueva, datos):
|
|
1198
|
+
"""Resolve question one, including the explicit CLI non-interactive path."""
|
|
1199
|
+
dominio_actual = domains.de_ciudad(datos)
|
|
1200
|
+
if a.domain:
|
|
1201
|
+
dominio = domains.canonico(a.domain)
|
|
1202
|
+
if not domains.obtiene(dominio):
|
|
1203
|
+
conocidos = ", ".join(p["id"] for p in domains.catalogo())
|
|
1204
|
+
print(f" Unknown domain {a.domain!r}. Available: {conocidos}.", file=sys.stderr)
|
|
1205
|
+
return None
|
|
1206
|
+
return dominio
|
|
1207
|
+
return pregunta_dominio(dominio_actual) if nueva else dominio_actual
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def rol_configurado(a, nueva, dominio, actual=""):
|
|
1211
|
+
"""Resolve question two; a domain switch always asks for a matching role."""
|
|
1212
|
+
if a.role:
|
|
1213
|
+
return a.role
|
|
1214
|
+
solo_otro_ajuste = a.goal or a.agent_roles or a.engines
|
|
1215
|
+
if nueva or a.domain or not solo_otro_ajuste:
|
|
1216
|
+
return pregunta_rol(dominio, actual)
|
|
1217
|
+
return actual or domains.roles_de(dominio)[0][0]
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def pregunta_seat_yolo(datos):
|
|
1221
|
+
"""Question six: whether the chair itself asks permission. Per city, in
|
|
1222
|
+
city.yml — locally the seat is the owner's own hands on the owner's own
|
|
1223
|
+
machine, and asking the owner for permission in their own chair is a
|
|
1224
|
+
choice, not a law. Repo windows keep their own yolo/cage story either way.
|
|
1225
|
+
False means the person quit the wizard."""
|
|
1226
|
+
yolo_actual = cities.lee_clave(datos, "seat_yolo") == "1"
|
|
1227
|
+
eleccion = ui.una(
|
|
1228
|
+
"Does your own chair ask permission?",
|
|
1229
|
+
[
|
|
1230
|
+
(
|
|
1231
|
+
"ask",
|
|
1232
|
+
"Ask first" + ("" if yolo_actual else " (current)"),
|
|
1233
|
+
"the seat confirms actions with you — the cautious default",
|
|
1234
|
+
),
|
|
1235
|
+
(
|
|
1236
|
+
"yolo",
|
|
1237
|
+
"No prompts (yolo)" + (" (current)" if yolo_actual else ""),
|
|
1238
|
+
"your chair acts without asking; repo windows keep their own cage",
|
|
1239
|
+
),
|
|
1240
|
+
],
|
|
1241
|
+
6,
|
|
1242
|
+
PASOS,
|
|
1243
|
+
ayuda="Changeable any time: ./bin/seat --seat-yolo on|off. "
|
|
1244
|
+
"Launching with --no-yolo still brakes the whole session, seat included.",
|
|
1245
|
+
)
|
|
1246
|
+
if eleccion is None:
|
|
1247
|
+
return False
|
|
1248
|
+
cities.pon_clave(datos, "seat_yolo", "1" if eleccion == "yolo" else "0")
|
|
1249
|
+
return True
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def pregunta_atajo(datos, nueva):
|
|
1253
|
+
"""Question seven: whether this city gets a door on the desktop.
|
|
1254
|
+
|
|
1255
|
+
A tool you have to remember how to start is a tool you stop starting. The
|
|
1256
|
+
shortcut runs the same command a person would type, so it adds a way in
|
|
1257
|
+
without adding a second implementation of anything. Only offered where a
|
|
1258
|
+
desktop exists, and only on a new city — nobody wants this asked weekly.
|
|
1259
|
+
"""
|
|
1260
|
+
if not nueva or not atajos.escritorio():
|
|
1261
|
+
return True
|
|
1262
|
+
eleccion = ui.una(
|
|
1263
|
+
"Put this city on your desktop?",
|
|
1264
|
+
[
|
|
1265
|
+
(
|
|
1266
|
+
"si",
|
|
1267
|
+
"Yes, with its icon",
|
|
1268
|
+
"double-click it and the city opens, tmux session and all",
|
|
1269
|
+
),
|
|
1270
|
+
("no", "No thanks", "you can add it later: agents-city shortcut"),
|
|
1271
|
+
],
|
|
1272
|
+
7,
|
|
1273
|
+
PASOS,
|
|
1274
|
+
ayuda="A real desktop shortcut carrying the city's name and a colour "
|
|
1275
|
+
"drawn from its own identity. It runs `agents-city seat --city …` — "
|
|
1276
|
+
"the same line you would type. Remove it any time: "
|
|
1277
|
+
"agents-city shortcut --remove.",
|
|
1278
|
+
)
|
|
1279
|
+
if eleccion is None:
|
|
1280
|
+
return False
|
|
1281
|
+
if eleccion == "si":
|
|
1282
|
+
ruta, mal = atajos.crea(datos)
|
|
1283
|
+
print(f" {mal}" if mal else f" On your desktop: {ruta}")
|
|
1284
|
+
return True
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def hay_ajustes(a, nueva):
|
|
1288
|
+
"""Whether this invocation re-asks any of the seven questions."""
|
|
1289
|
+
return bool(nueva or a.agent_roles or a.goal or a.domain or a.role or a.engines)
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def pregunta_runtime(a, nueva, datos):
|
|
1293
|
+
"""Questions five and six: what runs in the chair's own window, and whether
|
|
1294
|
+
it asks permission. None means the person quit; {} means neither question
|
|
1295
|
+
was due this invocation.
|
|
1296
|
+
|
|
1297
|
+
Every other window's engine belongs to its agent and is asked while that
|
|
1298
|
+
agent is being created — a late "what runs in each window?" pass was the
|
|
1299
|
+
old repo-shaped vocabulary talking.
|
|
1300
|
+
"""
|
|
1301
|
+
if not (nueva or a.engines):
|
|
1302
|
+
return {}
|
|
1303
|
+
motores = pregunta_motores([])
|
|
1304
|
+
if motores is None or not pregunta_seat_yolo(datos):
|
|
1305
|
+
return None
|
|
1306
|
+
return motores
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def ajusta_seat_yolo(a, datos, nueva):
|
|
1310
|
+
"""The --seat-yolo flag: write the per-city choice. Returns True when the
|
|
1311
|
+
invocation was ONLY this adjustment, so the caller can stop there."""
|
|
1312
|
+
if not a.seat_yolo:
|
|
1313
|
+
return False
|
|
1314
|
+
cities.pon_clave(datos, "seat_yolo", "1" if a.seat_yolo == "on" else "0")
|
|
1315
|
+
estado_seat = "yolo" if a.seat_yolo == "on" else "asks permission"
|
|
1316
|
+
print(f" Seat runtime for this city: {estado_seat} (city.yml seat_yolo).")
|
|
1317
|
+
solo = not hay_ajustes(a, nueva)
|
|
1318
|
+
if solo:
|
|
1319
|
+
print(" It applies the next time the session opens.\n")
|
|
1320
|
+
return solo
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def configura(a, ficha, nueva, datos, usuario):
|
|
1324
|
+
"""The seven questions, or the setting being re-asked. False means quit."""
|
|
1325
|
+
previo = open(ficha).read() if not nueva else ""
|
|
1326
|
+
|
|
1327
|
+
def campo(k, defecto=""):
|
|
1328
|
+
return card.campo(previo, k, defecto)
|
|
1329
|
+
|
|
1330
|
+
# Domain owns the vocabulary and relevant roles; it is never inferred from
|
|
1331
|
+
# repo names. Old `kind: product` cities resolve to software.
|
|
1332
|
+
dominio = dominio_configurado(a, nueva, datos)
|
|
1333
|
+
if not dominio:
|
|
1334
|
+
return False
|
|
1335
|
+
rol = rol_configurado(a, nueva, dominio, campo("role"))
|
|
1336
|
+
if not rol:
|
|
1337
|
+
return False
|
|
1338
|
+
|
|
1339
|
+
# 3. The roster, one agent at a time — each asked for in full.
|
|
1340
|
+
plantilla = nueva or a.agent_roles
|
|
1341
|
+
if plantilla:
|
|
1342
|
+
roster = pregunta_agentes(usuario, dominio, datos, agentes_de_ficha(ficha, datos))
|
|
1343
|
+
if roster is None:
|
|
1344
|
+
return False
|
|
1345
|
+
else:
|
|
1346
|
+
roster = agentes_de_ficha(ficha, datos)
|
|
1347
|
+
|
|
1348
|
+
# 4. The goal, which is the one thing a round cannot work without.
|
|
1349
|
+
objetivo = None
|
|
1350
|
+
if nueva or a.goal:
|
|
1351
|
+
objetivo = pregunta_objetivo(usuario)
|
|
1352
|
+
elif campo("goals_defined") == "true":
|
|
1353
|
+
objetivo = card.objetivo(previo, usuario)
|
|
1354
|
+
|
|
1355
|
+
# 5 and 6. The chair's own engine, and whether it asks permission. Every
|
|
1356
|
+
# other engine was decided on its own agent, where it belongs.
|
|
1357
|
+
motores = pregunta_runtime(a, nueva, datos)
|
|
1358
|
+
if motores is None:
|
|
1359
|
+
return False
|
|
1360
|
+
|
|
1361
|
+
# 7. The door on the desktop, offered once, when the city is new.
|
|
1362
|
+
if not pregunta_atajo(datos, nueva):
|
|
1363
|
+
return False
|
|
1364
|
+
|
|
1365
|
+
escribe_cambios(
|
|
1366
|
+
ficha,
|
|
1367
|
+
nueva,
|
|
1368
|
+
datos,
|
|
1369
|
+
usuario,
|
|
1370
|
+
dominio,
|
|
1371
|
+
rol,
|
|
1372
|
+
roster,
|
|
1373
|
+
objetivo,
|
|
1374
|
+
a.goal,
|
|
1375
|
+
motores,
|
|
1376
|
+
plantilla,
|
|
1377
|
+
)
|
|
1378
|
+
resume(datos, usuario, dominio, rol, roster, objetivo)
|
|
1379
|
+
return True
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def agentes_de_ficha(ficha, datos):
|
|
1383
|
+
"""The roster already on a card, in the wizard's own shape.
|
|
1384
|
+
|
|
1385
|
+
Reads through `workspace.agentes`, so a legacy `repos:` card arrives as the
|
|
1386
|
+
agents it always was — one per repo, each mounting itself — and re-running
|
|
1387
|
+
the wizard over it upgrades rather than discards.
|
|
1388
|
+
"""
|
|
1389
|
+
try:
|
|
1390
|
+
texto = card.lee(ficha).get("texto") or ""
|
|
1391
|
+
except OSError:
|
|
1392
|
+
return []
|
|
1393
|
+
try:
|
|
1394
|
+
vivos = workspace.agentes(texto, datos)
|
|
1395
|
+
except ValueError:
|
|
1396
|
+
return []
|
|
1397
|
+
return [workspace.como_ficha(a) for a in vivos]
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
# What a roster looks like on a card is workspace.py's fact, not the wizard's:
|
|
1401
|
+
# the Hall writes the same keys from a POST, and two writers of one shape drift.
|
|
1402
|
+
claves_de_agentes = workspace.claves_de_roster
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def materializa_agentes(datos, ficha):
|
|
1406
|
+
"""Give every agent on the card its workspace and its mounts on disk.
|
|
1407
|
+
|
|
1408
|
+
The card is the source of truth, so this reads it back rather than trusting
|
|
1409
|
+
the answers in memory: what the launcher and the cage will see is exactly
|
|
1410
|
+
what gets built here."""
|
|
1411
|
+
try:
|
|
1412
|
+
texto = card.lee(ficha).get("texto") or ""
|
|
1413
|
+
vivos = workspace.agentes(texto, datos)
|
|
1414
|
+
except (OSError, ValueError):
|
|
1415
|
+
return []
|
|
1416
|
+
hechos = []
|
|
1417
|
+
for a in vivos:
|
|
1418
|
+
destinos = workspace.sincroniza(a, datos)
|
|
1419
|
+
hechos.append((a.nombre, len(destinos)))
|
|
1420
|
+
return hechos
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def escribe_cambios(
|
|
1424
|
+
ficha,
|
|
1425
|
+
nueva,
|
|
1426
|
+
datos,
|
|
1427
|
+
usuario,
|
|
1428
|
+
dominio,
|
|
1429
|
+
rol,
|
|
1430
|
+
roster,
|
|
1431
|
+
objetivo,
|
|
1432
|
+
con_goal,
|
|
1433
|
+
motores,
|
|
1434
|
+
actualiza_roster,
|
|
1435
|
+
):
|
|
1436
|
+
"""Land the answers on the card — the whole template for a new one, surgery
|
|
1437
|
+
for one that exists, because an existing card may carry round history and
|
|
1438
|
+
changing your role must not erase what happened."""
|
|
1439
|
+
if nueva:
|
|
1440
|
+
escribe_ficha(ficha, usuario, rol, roster, objetivo, cities.slug_ciudad(datos))
|
|
1441
|
+
else:
|
|
1442
|
+
card.cambia_rol(
|
|
1443
|
+
ficha,
|
|
1444
|
+
rol,
|
|
1445
|
+
roles.agente(usuario, rol, cities.slug_ciudad(datos)),
|
|
1446
|
+
roles.oficio(rol, datos),
|
|
1447
|
+
)
|
|
1448
|
+
if con_goal or objetivo is not None:
|
|
1449
|
+
card.cambia_objetivo(ficha, objetivo)
|
|
1450
|
+
# After the template or the surgery, either way: pon_campo is itself surgical.
|
|
1451
|
+
if actualiza_roster or nueva:
|
|
1452
|
+
for clave, valor in claves_de_agentes(roster).items():
|
|
1453
|
+
card.pon_campo(ficha, clave, valor)
|
|
1454
|
+
for clave, valor in motores.items():
|
|
1455
|
+
card.pon_campo(ficha, clave, valor)
|
|
1456
|
+
materializa_agentes(datos, ficha)
|
|
1457
|
+
domains.selecciona(datos, dominio)
|
|
1458
|
+
conocimiento = domains.materializa(datos, dominio, rol)
|
|
1459
|
+
for rol_agente in sorted({a["rol"] for a in roster} - {"blank", rol}):
|
|
1460
|
+
conocimiento += domains.materializa(datos, dominio, rol_agente)
|
|
1461
|
+
if conocimiento:
|
|
1462
|
+
print(" Domain knowledge installed as editable files: " + ", ".join(conocimiento) + ".")
|
|
1463
|
+
otros = sorted(k.split(".", 1)[1] for k, v in motores.items() if v and k.startswith("runs."))
|
|
1464
|
+
otros += sorted(
|
|
1465
|
+
a["slug"] for a in roster if (a.get("motor") or {}).get(f"runs.{a['slug']}")
|
|
1466
|
+
)
|
|
1467
|
+
if otros:
|
|
1468
|
+
print(f" Windows on another engine: {', '.join(otros)} — the rest run Claude.")
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def resume(datos, usuario, dominio, rol, roster, objetivo):
|
|
1472
|
+
"""Say what was written, and what to do with it."""
|
|
1473
|
+
print(f" City address: {cities.direccion(usuario, datos)}.")
|
|
1474
|
+
pack = domains.obtiene(dominio)
|
|
1475
|
+
print(f" Domain: {pack['name'] if pack else dominio}.")
|
|
1476
|
+
print(f" Your role there: {roles.nombre(rol)}.")
|
|
1477
|
+
if roster:
|
|
1478
|
+
print(
|
|
1479
|
+
f" {len(roster)} agent{'s' if len(roster) != 1 else ''} in this city — "
|
|
1480
|
+
f"one window each. Change the roster with ./bin/seat --agents"
|
|
1481
|
+
)
|
|
1482
|
+
for a in roster:
|
|
1483
|
+
trabajo = (
|
|
1484
|
+
f"{len(a['mounts'])} mount{'s' if len(a['mounts']) != 1 else ''}"
|
|
1485
|
+
if a["mounts"]
|
|
1486
|
+
else "nothing mounted yet"
|
|
1487
|
+
)
|
|
1488
|
+
extra = f", skills: {', '.join(a['skills'])}" if a.get("skills") else ""
|
|
1489
|
+
print(f" · {a['nombre']} — {a['clase']}, {roles.nombre(a['rol'])}, {trabajo}{extra}")
|
|
1490
|
+
else:
|
|
1491
|
+
print(
|
|
1492
|
+
" No agents in this city yet. Its seat still opens on its own;\n"
|
|
1493
|
+
" roads can connect it to other local or remote cities."
|
|
1494
|
+
)
|
|
1495
|
+
print(f" Goal: {objetivo['title'] if objetivo else 'none yet — ./bin/seat --goal'}")
|
|
1496
|
+
# Only in our own folder: a real data repo's modelling is not ours to touch.
|
|
1497
|
+
if cities.gestionada(datos, usuario):
|
|
1498
|
+
for h in escribe_suelo(datos, [a["nombre"] for a in roster], dominio):
|
|
1499
|
+
print(f" · {h}")
|
|
1500
|
+
if roster:
|
|
1501
|
+
print(
|
|
1502
|
+
f"\n Your city is drawable now — no wizard, no account:\n"
|
|
1503
|
+
f" ./bin/city {datos.replace(os.path.expanduser('~'), '~')}"
|
|
1504
|
+
)
|
|
1505
|
+
print()
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def ciudad_elegida(cual, usuario):
|
|
1509
|
+
"""--city resolved to a folder, the usual resolution otherwise, '' plus the
|
|
1510
|
+
list of known cities on a miss — a wrong name should teach, not strand."""
|
|
1511
|
+
if not cual:
|
|
1512
|
+
return donde_viven_las_fichas(usuario)
|
|
1513
|
+
datos = cities.resuelve(cual, usuario)
|
|
1514
|
+
if not datos:
|
|
1515
|
+
conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none yet"
|
|
1516
|
+
print(
|
|
1517
|
+
f" No city called {cual!r}. Known here: {conocidas}.\n"
|
|
1518
|
+
f" A path works too: ./bin/seat --city ~/clientes/acme\n",
|
|
1519
|
+
file=sys.stderr,
|
|
1520
|
+
)
|
|
1521
|
+
return datos
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def entrega_a_la_sesion(a, datos, usuario):
|
|
1525
|
+
"""Hand over to the session builder, which is the part that already worked.
|
|
1526
|
+
|
|
1527
|
+
This replaces the process rather than waiting on it: from here on the session
|
|
1528
|
+
script owns the terminal, and `seat` has no more decisions to take. The two
|
|
1529
|
+
engine flags travel as environment because they belong to THIS launch and must
|
|
1530
|
+
not be written to anybody's card.
|
|
1531
|
+
"""
|
|
1532
|
+
orden = [os.path.join(GUIONES, "city-session.sh"), usuario, "--claude"]
|
|
1533
|
+
if a.no_yolo:
|
|
1534
|
+
orden.append("--no-yolo")
|
|
1535
|
+
if a.no_sync:
|
|
1536
|
+
orden.append("--no-sync")
|
|
1537
|
+
if a.only:
|
|
1538
|
+
orden += ["--only", a.only]
|
|
1539
|
+
entorno = dict(
|
|
1540
|
+
os.environ,
|
|
1541
|
+
AGENTS_CITY_DATA=datos,
|
|
1542
|
+
AGENTS_CITY_HOME=cities.raiz(),
|
|
1543
|
+
AGENTS_CITY_USER=usuario,
|
|
1544
|
+
CITY_ADDRESS=cities.direccion(usuario, datos),
|
|
1545
|
+
)
|
|
1546
|
+
if a.model:
|
|
1547
|
+
entorno["CITY_MODEL"] = a.model
|
|
1548
|
+
if a.effort:
|
|
1549
|
+
entorno["CITY_EFFORT"] = a.effort
|
|
1550
|
+
os.execve(orden[0], orden, entorno)
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def argumentos():
|
|
1554
|
+
"""Every flag the seat takes. Its own function so `main` stays readable: the
|
|
1555
|
+
door's job is the order of the steps, not the shape of the arguments."""
|
|
1556
|
+
ap = argparse.ArgumentParser(add_help=False)
|
|
1557
|
+
ap.add_argument("usuario", nargs="?", default="")
|
|
1558
|
+
# One question, one flag. `--repos` and `--agent-roles` are the names this
|
|
1559
|
+
# asked by when a repo WAS an agent; they still work, and they open the same
|
|
1560
|
+
# roster, because breaking somebody's muscle memory buys nothing.
|
|
1561
|
+
ap.add_argument(
|
|
1562
|
+
"--agents",
|
|
1563
|
+
"--agent-roles",
|
|
1564
|
+
"--repos",
|
|
1565
|
+
action="store_true",
|
|
1566
|
+
dest="agent_roles",
|
|
1567
|
+
help="build the roster again, agent by agent (--repos is the old name for it)",
|
|
1568
|
+
)
|
|
1569
|
+
ap.add_argument("--goal", action="store_true", help="set your goal again")
|
|
1570
|
+
ap.add_argument(
|
|
1571
|
+
"--engines", action="store_true", help="what runs in each window: a model, or another CLI"
|
|
1572
|
+
)
|
|
1573
|
+
ap.add_argument(
|
|
1574
|
+
"--city",
|
|
1575
|
+
default="",
|
|
1576
|
+
metavar="NAME|PATH",
|
|
1577
|
+
help="which city to sit in (default: the usual resolution)",
|
|
1578
|
+
)
|
|
1579
|
+
ap.add_argument(
|
|
1580
|
+
"--domain",
|
|
1581
|
+
default="",
|
|
1582
|
+
metavar="DOMAIN",
|
|
1583
|
+
help="set the work domain (software, healthcare, legal...)",
|
|
1584
|
+
)
|
|
1585
|
+
ap.add_argument("--role", default="", help="set your role without being asked")
|
|
1586
|
+
ap.add_argument("--no-yolo", action="store_true")
|
|
1587
|
+
ap.add_argument(
|
|
1588
|
+
"--seat-yolo",
|
|
1589
|
+
default="",
|
|
1590
|
+
choices=["on", "off"],
|
|
1591
|
+
metavar="on|off",
|
|
1592
|
+
help="whether the chair itself runs without permission prompts (stored per city)",
|
|
1593
|
+
)
|
|
1594
|
+
ap.add_argument("--no-sync", action="store_true")
|
|
1595
|
+
ap.add_argument("--only", default="")
|
|
1596
|
+
ap.add_argument(
|
|
1597
|
+
"--model",
|
|
1598
|
+
default="",
|
|
1599
|
+
metavar="ALIAS",
|
|
1600
|
+
help="this launch's model for every window (opus, sonnet, haiku…)",
|
|
1601
|
+
)
|
|
1602
|
+
ap.add_argument(
|
|
1603
|
+
"--effort",
|
|
1604
|
+
default="",
|
|
1605
|
+
metavar="LEVEL",
|
|
1606
|
+
help="this launch's effort (low, medium, high, xhigh, max)",
|
|
1607
|
+
)
|
|
1608
|
+
ap.add_argument("-h", "--help", action="store_true")
|
|
1609
|
+
return ap.parse_args()
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def puerta_del_dueno(a, usuario):
|
|
1613
|
+
"""The two ownership refusals at the door: a second person cannot take this
|
|
1614
|
+
seat, and another owner's city cannot be sat in. Returns the city folder,
|
|
1615
|
+
or None with the reason already printed."""
|
|
1616
|
+
if a.usuario and gh.usuario_de_correo(a.usuario) != usuario:
|
|
1617
|
+
print(
|
|
1618
|
+
" A personal city has exactly one owner seat. To open another of "
|
|
1619
|
+
"your cities, use `--city <name>`.",
|
|
1620
|
+
file=sys.stderr,
|
|
1621
|
+
)
|
|
1622
|
+
return None
|
|
1623
|
+
# v1 put the whole first city directly in ~/.agents-city. Move it once, with
|
|
1624
|
+
# a recovery copy, before resolving the selected v2 city.
|
|
1625
|
+
if not os.environ.get("AGENTS_CITY_DATA"):
|
|
1626
|
+
cities.migra_legacy(usuario, anunciar=True)
|
|
1627
|
+
datos = ciudad_elegida(a.city, usuario)
|
|
1628
|
+
if not datos:
|
|
1629
|
+
return None
|
|
1630
|
+
owner = cities.lee_clave(datos, "owner")
|
|
1631
|
+
if owner and owner != usuario:
|
|
1632
|
+
print(
|
|
1633
|
+
f" This city belongs to {owner!r}, not {usuario!r}. Import one of "
|
|
1634
|
+
"your own cities or connect it by road instead.",
|
|
1635
|
+
file=sys.stderr,
|
|
1636
|
+
)
|
|
1637
|
+
return None
|
|
1638
|
+
return datos
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def main():
|
|
1642
|
+
a = argumentos()
|
|
1643
|
+
if a.help:
|
|
1644
|
+
print(__doc__)
|
|
1645
|
+
return 0
|
|
1646
|
+
|
|
1647
|
+
usuario = quien_soy()
|
|
1648
|
+
datos = puerta_del_dueno(a, usuario)
|
|
1649
|
+
if not datos:
|
|
1650
|
+
return 1
|
|
1651
|
+
# Remember it, so every door — the hall's switcher included — can offer it.
|
|
1652
|
+
cities.asegura_metadata(datos, usuario)
|
|
1653
|
+
cities.registra(datos, usuario)
|
|
1654
|
+
cities.selecciona(usuario, datos)
|
|
1655
|
+
ficha = os.path.join(datos, f"{usuario}.md")
|
|
1656
|
+
nueva = not os.path.exists(ficha)
|
|
1657
|
+
|
|
1658
|
+
if ajusta_seat_yolo(a, datos, nueva):
|
|
1659
|
+
return 0
|
|
1660
|
+
|
|
1661
|
+
if hay_ajustes(a, nueva):
|
|
1662
|
+
if nueva:
|
|
1663
|
+
print(
|
|
1664
|
+
f"\n First time here. Seven questions and you are in — the work "
|
|
1665
|
+
f"domain, your chair role,\n your folders and their agent roles, "
|
|
1666
|
+
f"one goal, what runs in each window, "
|
|
1667
|
+
f"and whether\n your own chair asks permission. "
|
|
1668
|
+
f"Your card lands at\n "
|
|
1669
|
+
f"{ficha.replace(os.path.expanduser('~'), '~')}\n"
|
|
1670
|
+
)
|
|
1671
|
+
if not configura(a, ficha, nueva, datos, usuario):
|
|
1672
|
+
print(" Nothing changed.\n")
|
|
1673
|
+
return 1
|
|
1674
|
+
|
|
1675
|
+
# Cards written before personal cities addressed the role (`user/lead`). A
|
|
1676
|
+
# city address is stable when the role changes, and two cities never collide.
|
|
1677
|
+
esperado = cities.direccion(usuario, datos)
|
|
1678
|
+
leida = card.lee(ficha)
|
|
1679
|
+
if leida.get("agent") != esperado:
|
|
1680
|
+
card.pon_campo(ficha, "agent", esperado)
|
|
1681
|
+
|
|
1682
|
+
# Everything the session needs, installed rather than asked for. One command
|
|
1683
|
+
# means one command: "now go and install tmux" is a second one.
|
|
1684
|
+
if not asegura_tmux():
|
|
1685
|
+
return 1
|
|
1686
|
+
agente = card.lee(ficha).get("agent") or cities.direccion(usuario, datos)
|
|
1687
|
+
# No Claude in the seat window, no reason to install a Claude plugin — and no
|
|
1688
|
+
# reason to make somebody restart Claude Code for something they will not use.
|
|
1689
|
+
if not card.campo(open(ficha, encoding="utf-8").read(), "runs.seat"):
|
|
1690
|
+
asegura_plugin(usuario, agente, datos)
|
|
1691
|
+
|
|
1692
|
+
entrega_a_la_sesion(a, datos, usuario)
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
if __name__ == "__main__":
|
|
1696
|
+
sys.exit(main())
|