agents-city 0.3.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2021 -0
- package/README.md +1999 -0
- package/benchmarks/committee/README.md +39 -0
- package/benchmarks/committee/metrics.py +51 -0
- package/benchmarks/committee/run.py +64 -0
- package/benchmarks/committee/traces/01-single.json +8 -0
- package/benchmarks/committee/traces/02-mesh.json +16 -0
- package/benchmarks/committee/traces/03-chair.json +15 -0
- package/benchmarks/latency/README.md +35 -0
- package/benchmarks/latency/fake-claude-cli.mjs +119 -0
- package/benchmarks/latency/fake-native-server.mjs +340 -0
- package/benchmarks/latency/fake-tui.py +91 -0
- package/benchmarks/latency/live.py +380 -0
- package/benchmarks/stress/README.md +20 -0
- package/benchmarks/stress/run.py +487 -0
- package/bin/agents +4 -0
- package/bin/agents-city.js +116 -0
- package/bin/benchmark +17 -0
- package/bin/bus +6 -0
- package/bin/cities +3 -0
- package/bin/city +86 -0
- package/bin/committee +6 -0
- package/bin/demo +200 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +426 -0
- package/bin/logs +3 -0
- package/bin/report.py +14 -0
- package/bin/reset +3 -0
- package/bin/road +3 -0
- package/bin/seat +10 -0
- package/bin/serve.py +1428 -0
- package/bin/setup.py +753 -0
- package/bin/skills +3 -0
- package/bin/test +131 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-avatar.py +58 -0
- package/bin/test-benchmark.py +40 -0
- package/bin/test-broker.py +334 -0
- package/bin/test-cage.py +206 -0
- package/bin/test-card.py +402 -0
- package/bin/test-channel.py +379 -0
- package/bin/test-cities.py +453 -0
- package/bin/test-claude-runtime.py +252 -0
- package/bin/test-committee.py +340 -0
- package/bin/test-contracts.py +544 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +106 -0
- package/bin/test-domains.py +151 -0
- package/bin/test-evidencia.py +50 -0
- package/bin/test-exit.py +128 -0
- package/bin/test-hall-protocol.py +93 -0
- package/bin/test-launch.py +169 -0
- package/bin/test-live-feed.py +508 -0
- package/bin/test-pairing.py +99 -0
- package/bin/test-parcels.py +283 -0
- package/bin/test-runtime-failures.py +252 -0
- package/bin/test-runtime-ui.py +488 -0
- package/bin/test-runtime.py +268 -0
- package/bin/test-rutas.py +80 -0
- package/bin/test-seat.py +1386 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1057 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +188 -0
- package/bin/testlib.py +95 -0
- package/bin/tokens.py +14 -0
- package/bus/scripts/deploy.sh +60 -0
- package/bus/scripts/mint-token.sh +68 -0
- package/bus/scripts/setup-dev.sh +79 -0
- package/bus/scripts/test-channel.ts +8 -0
- package/bus/scripts/test-hub.ts +266 -0
- package/bus/scripts/test-local.sh +40 -0
- package/bus/scripts/test-queue.ts +132 -0
- package/bus/worker/package-lock.json +1971 -0
- package/bus/worker/package.json +17 -0
- package/bus/worker/src/index.ts +485 -0
- package/bus/worker/tsconfig.json +13 -0
- package/bus/worker/wrangler.toml +32 -0
- package/city/oven/README.md +59 -0
- package/city/oven/collect.py +43 -0
- package/city/oven/oven.html +4 -0
- package/city/scripts/history.py +120 -0
- package/city/scripts/seed.py +442 -0
- package/city/web/assets/modelos/License.txt +28 -0
- package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/assets/modelos/building-a.glb +0 -0
- package/city/web/assets/modelos/building-b.glb +0 -0
- package/city/web/assets/modelos/building-c.glb +0 -0
- package/city/web/assets/modelos/building-d.glb +0 -0
- package/city/web/assets/modelos/building-e.glb +0 -0
- package/city/web/assets/modelos/building-f.glb +0 -0
- package/city/web/assets/modelos/building-g.glb +0 -0
- package/city/web/assets/modelos/building-h.glb +0 -0
- package/city/web/assets/sprites/building-a.png +0 -0
- package/city/web/assets/sprites/building-b.png +0 -0
- package/city/web/assets/sprites/building-c.png +0 -0
- package/city/web/assets/sprites/building-d.png +0 -0
- package/city/web/assets/sprites/building-e.png +0 -0
- package/city/web/assets/sprites/building-f.png +0 -0
- package/city/web/assets/sprites/building-g.png +0 -0
- package/city/web/assets/sprites/building-h.png +0 -0
- package/city/web/assets/sprites/building-i.png +0 -0
- package/city/web/assets/sprites/building-j.png +0 -0
- package/city/web/assets/sprites/building-k.png +0 -0
- package/city/web/assets/sprites/building-l.png +0 -0
- package/city/web/assets/sprites/building-m.png +0 -0
- package/city/web/assets/sprites/building-n.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
- package/city/web/assets/sprites/catalogo.json +47 -0
- package/city/web/assets/sprites/detail-awning-wide.png +0 -0
- package/city/web/assets/sprites/detail-awning.png +0 -0
- package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
- package/city/web/assets/sprites/detail-overhang.png +0 -0
- package/city/web/assets/sprites/detail-parasol-a.png +0 -0
- package/city/web/assets/sprites/detail-parasol-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-c.png +0 -0
- package/city/web/assets/sprites/low-detail-building-d.png +0 -0
- package/city/web/assets/sprites/low-detail-building-e.png +0 -0
- package/city/web/assets/sprites/low-detail-building-f.png +0 -0
- package/city/web/assets/sprites/low-detail-building-g.png +0 -0
- package/city/web/assets/sprites/low-detail-building-h.png +0 -0
- package/city/web/assets/sprites/low-detail-building-i.png +0 -0
- package/city/web/assets/sprites/low-detail-building-j.png +0 -0
- package/city/web/assets/sprites/low-detail-building-k.png +0 -0
- package/city/web/assets/sprites/low-detail-building-l.png +0 -0
- package/city/web/assets/sprites/low-detail-building-m.png +0 -0
- package/city/web/assets/sprites/low-detail-building-n.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/assets/sprites/medidas.json +248 -0
- package/city/web/dist/city.js +939 -0
- package/city/web/dist/index.html +294 -0
- package/city/web/dist/modelos/License.txt +28 -0
- package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/dist/modelos/building-a.glb +0 -0
- package/city/web/dist/modelos/building-b.glb +0 -0
- package/city/web/dist/modelos/building-c.glb +0 -0
- package/city/web/dist/modelos/building-d.glb +0 -0
- package/city/web/dist/modelos/building-e.glb +0 -0
- package/city/web/dist/modelos/building-f.glb +0 -0
- package/city/web/dist/modelos/building-g.glb +0 -0
- package/city/web/dist/modelos/building-h.glb +0 -0
- package/city/web/dist/sprites/building-a.png +0 -0
- package/city/web/dist/sprites/building-b.png +0 -0
- package/city/web/dist/sprites/building-c.png +0 -0
- package/city/web/dist/sprites/building-d.png +0 -0
- package/city/web/dist/sprites/building-e.png +0 -0
- package/city/web/dist/sprites/building-f.png +0 -0
- package/city/web/dist/sprites/building-g.png +0 -0
- package/city/web/dist/sprites/building-h.png +0 -0
- package/city/web/dist/sprites/building-i.png +0 -0
- package/city/web/dist/sprites/building-j.png +0 -0
- package/city/web/dist/sprites/building-k.png +0 -0
- package/city/web/dist/sprites/building-l.png +0 -0
- package/city/web/dist/sprites/building-m.png +0 -0
- package/city/web/dist/sprites/building-n.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
- package/city/web/dist/sprites/catalogo.json +47 -0
- package/city/web/dist/sprites/detail-awning-wide.png +0 -0
- package/city/web/dist/sprites/detail-awning.png +0 -0
- package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
- package/city/web/dist/sprites/detail-overhang.png +0 -0
- package/city/web/dist/sprites/detail-parasol-a.png +0 -0
- package/city/web/dist/sprites/detail-parasol-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-c.png +0 -0
- package/city/web/dist/sprites/low-detail-building-d.png +0 -0
- package/city/web/dist/sprites/low-detail-building-e.png +0 -0
- package/city/web/dist/sprites/low-detail-building-f.png +0 -0
- package/city/web/dist/sprites/low-detail-building-g.png +0 -0
- package/city/web/dist/sprites/low-detail-building-h.png +0 -0
- package/city/web/dist/sprites/low-detail-building-i.png +0 -0
- package/city/web/dist/sprites/low-detail-building-j.png +0 -0
- package/city/web/dist/sprites/low-detail-building-k.png +0 -0
- package/city/web/dist/sprites/low-detail-building-l.png +0 -0
- package/city/web/dist/sprites/low-detail-building-m.png +0 -0
- package/city/web/dist/sprites/low-detail-building-n.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/dist/sprites/medidas.json +248 -0
- package/city/web/dist-hall/hall.js +264 -0
- package/city/web/index.html +294 -0
- package/city/web/package.json +18 -0
- package/city/web/sella.py +21 -0
- package/city/web/src/activity-actors.ts +47 -0
- package/city/web/src/activity.ts +172 -0
- package/city/web/src/ayuntamiento.ts +580 -0
- package/city/web/src/draw.ts +640 -0
- package/city/web/src/game-speech.ts +79 -0
- package/city/web/src/hall.ts +1867 -0
- package/city/web/src/main.ts +2225 -0
- package/city/web/src/oven.ts +124 -0
- package/city/web/src/people.ts +244 -0
- package/city/web/src/presencia.ts +193 -0
- package/city/web/src/puertas.ts +180 -0
- package/city/web/src/repo-roles.ts +59 -0
- package/city/worker/package.json +16 -0
- package/city/worker/schema.sql +117 -0
- package/city/worker/src/index.ts +702 -0
- package/city/worker/src/square.ts +218 -0
- package/city/worker/wrangler.toml +58 -0
- package/demo/ada.md +50 -0
- package/demo/bruno.md +62 -0
- package/demo/camila.md +56 -0
- package/demo/city/ada.md +40 -0
- package/demo/city/city.yml +8 -0
- package/demo/city/parcels.yml +15 -0
- package/demo/city/roads.json +4 -0
- package/demo/city/units.yml +10 -0
- package/demo/clinica/city.yml +8 -0
- package/demo/clinica/parcels.yml +15 -0
- package/demo/clinica/roads.json +4 -0
- package/demo/clinica/units.yml +9 -0
- package/demo/clinica/vera.md +46 -0
- package/demo/dante.md +54 -0
- package/demo/despacho/city.yml +8 -0
- package/demo/despacho/marta.md +46 -0
- package/demo/despacho/parcels.yml +15 -0
- package/demo/despacho/roads.json +4 -0
- package/demo/despacho/units.yml +9 -0
- package/demo/elsa.md +50 -0
- package/demo/farid.md +52 -0
- package/demo/greta.md +50 -0
- package/demo/hugo.md +49 -0
- package/demo/iris.md +49 -0
- package/demo/jonas.md +51 -0
- package/demo/kira.md +51 -0
- package/demo/luca.md +49 -0
- package/demo/parcels.yml +92 -0
- package/demo/seed.py +20 -0
- package/demo/show.py +161 -0
- package/demo/stories.py +824 -0
- package/demo/units.yml +33 -0
- package/docs/agents-first.md +99 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +231 -0
- package/docs/self-host.md +151 -0
- package/docs/testing.md +205 -0
- package/package.json +73 -0
- package/plugin/.claude-plugin/plugin.json +62 -0
- package/plugin/.mcp.json +15 -0
- package/plugin/channel/activity-cli.ts +16 -0
- package/plugin/channel/adapter-prompts.ts +113 -0
- package/plugin/channel/adapter.js +4292 -0
- package/plugin/channel/adapter.ts +133 -0
- package/plugin/channel/bus.js +20188 -0
- package/plugin/channel/bus.ts +131 -0
- package/plugin/channel/city-config.ts +125 -0
- package/plugin/channel/claude-channel.ts +58 -0
- package/plugin/channel/cli-args.ts +75 -0
- package/plugin/channel/client.js +4200 -0
- package/plugin/channel/client.ts +38 -0
- package/plugin/channel/committee/activity.ts +256 -0
- package/plugin/channel/committee/collection.ts +154 -0
- package/plugin/channel/committee/decision.ts +175 -0
- package/plugin/channel/committee/floor.ts +135 -0
- package/plugin/channel/committee/guards.ts +30 -0
- package/plugin/channel/committee/history.ts +38 -0
- package/plugin/channel/committee/render.ts +102 -0
- package/plugin/channel/committee/service.ts +72 -0
- package/plugin/channel/committee/storage.ts +92 -0
- package/plugin/channel/committee/types.ts +163 -0
- package/plugin/channel/committee/view.ts +63 -0
- package/plugin/channel/committee-cli.ts +109 -0
- package/plugin/channel/delivery-metrics.ts +61 -0
- package/plugin/channel/delivery-queue.ts +118 -0
- package/plugin/channel/hub/activity-controller.ts +72 -0
- package/plugin/channel/hub/activity-feed.ts +169 -0
- package/plugin/channel/hub/committee-controller.ts +60 -0
- package/plugin/channel/hub/connections.ts +48 -0
- package/plugin/channel/hub/diagnostics.ts +67 -0
- package/plugin/channel/hub/envelope-validity.ts +109 -0
- package/plugin/channel/hub/envelopes.ts +66 -0
- package/plugin/channel/hub/lifecycle.ts +91 -0
- package/plugin/channel/hub/local-roads.ts +89 -0
- package/plugin/channel/hub/remote-roads.ts +165 -0
- package/plugin/channel/hub/road-controller.ts +99 -0
- package/plugin/channel/hub-client.ts +149 -0
- package/plugin/channel/local-hub.js +5995 -0
- package/plugin/channel/local-hub.ts +349 -0
- package/plugin/channel/map-reporter.ts +48 -0
- package/plugin/channel/package-lock.json +1731 -0
- package/plugin/channel/package.json +19 -0
- package/plugin/channel/protocol.ts +98 -0
- package/plugin/channel/road-cli.ts +18 -0
- package/plugin/channel/run.sh +15 -0
- package/plugin/channel/runtime/claude.ts +458 -0
- package/plugin/channel/runtime/codex-config.ts +77 -0
- package/plugin/channel/runtime/codex.ts +669 -0
- package/plugin/channel/runtime/command.ts +62 -0
- package/plugin/channel/runtime/factory.ts +15 -0
- package/plugin/channel/runtime/json-rpc.ts +154 -0
- package/plugin/channel/runtime/kimi.ts +277 -0
- package/plugin/channel/runtime/opencode.ts +248 -0
- package/plugin/channel/runtime/process.ts +84 -0
- package/plugin/channel/runtime/types.ts +45 -0
- package/plugin/channel/runtime-files.ts +73 -0
- package/plugin/channel/runtime-gateway.js +6396 -0
- package/plugin/channel/runtime-gateway.ts +358 -0
- package/plugin/channel/runtime-metrics.ts +52 -0
- package/plugin/channel/runtime-subscription.ts +180 -0
- package/plugin/channel/terminal-delivery.ts +131 -0
- package/plugin/channel/untrusted.ts +60 -0
- package/plugin/commands/committee.md +31 -0
- package/plugin/commands/exit.md +32 -0
- package/plugin/commands/goals.md +20 -0
- package/plugin/commands/join.md +18 -0
- package/plugin/commands/notice.md +20 -0
- package/plugin/commands/propose.md +19 -0
- package/plugin/commands/round.md +17 -0
- package/plugin/commands/session.md +22 -0
- package/plugin/commands/settings.md +25 -0
- package/plugin/commands/setup.md +21 -0
- package/plugin/commands/team.md +23 -0
- package/plugin/domains/custom.md +27 -0
- package/plugin/domains/finance.md +31 -0
- package/plugin/domains/healthcare.md +34 -0
- package/plugin/domains/legal.md +31 -0
- package/plugin/domains/marketing.md +33 -0
- package/plugin/domains/operations.md +31 -0
- package/plugin/domains/research.md +32 -0
- package/plugin/domains/sales.md +30 -0
- package/plugin/domains/software.md +37 -0
- package/plugin/hooks/activity.sh +6 -0
- package/plugin/hooks/digging.sh +68 -0
- package/plugin/hooks/growth.sh +42 -0
- package/plugin/hooks/hooks.json +106 -0
- package/plugin/hooks/notice-on-pr.sh +35 -0
- package/plugin/hooks/notice-on-stop.sh +80 -0
- package/plugin/hooks/notice-pending.sh +58 -0
- package/plugin/hooks/solo-en-ciudad.sh +23 -0
- package/plugin/hooks/tokens.sh +47 -0
- package/plugin/roles/examples/account-executive.md +25 -0
- package/plugin/roles/examples/ai-manager.md +34 -0
- package/plugin/roles/examples/associate.md +24 -0
- package/plugin/roles/examples/brand-lead.md +20 -0
- package/plugin/roles/examples/cfo.md +20 -0
- package/plugin/roles/examples/city-lead.md +25 -0
- package/plugin/roles/examples/clinical-director.md +26 -0
- package/plugin/roles/examples/clinical-ops.md +25 -0
- package/plugin/roles/examples/clinician.md +25 -0
- package/plugin/roles/examples/compliance.md +27 -0
- package/plugin/roles/examples/content.md +24 -0
- package/plugin/roles/examples/controller.md +27 -0
- package/plugin/roles/examples/cpto.md +41 -0
- package/plugin/roles/examples/customer-success.md +25 -0
- package/plugin/roles/examples/data-engineer.md +37 -0
- package/plugin/roles/examples/data.md +60 -0
- package/plugin/roles/examples/dev.md +37 -0
- package/plugin/roles/examples/devops.md +39 -0
- package/plugin/roles/examples/enablement.md +24 -0
- package/plugin/roles/examples/ethics.md +24 -0
- package/plugin/roles/examples/fin-analytics.md +27 -0
- package/plugin/roles/examples/health-compliance.md +25 -0
- package/plugin/roles/examples/health-data.md +26 -0
- package/plugin/roles/examples/knowledge.md +25 -0
- package/plugin/roles/examples/lifecycle.md +26 -0
- package/plugin/roles/examples/llm-engineer.md +34 -0
- package/plugin/roles/examples/managing-partner.md +20 -0
- package/plugin/roles/examples/methods.md +23 -0
- package/plugin/roles/examples/operations-lead.md +23 -0
- package/plugin/roles/examples/ops.md +24 -0
- package/plugin/roles/examples/patient-safety.md +26 -0
- package/plugin/roles/examples/performance.md +27 -0
- package/plugin/roles/examples/po.md +36 -0
- package/plugin/roles/examples/process-owner.md +23 -0
- package/plugin/roles/examples/product-design.md +39 -0
- package/plugin/roles/examples/program-manager.md +24 -0
- package/plugin/roles/examples/quality.md +25 -0
- package/plugin/roles/examples/research-director.md +24 -0
- package/plugin/roles/examples/research-ops.md +25 -0
- package/plugin/roles/examples/researcher.md +25 -0
- package/plugin/roles/examples/revenue-lead.md +25 -0
- package/plugin/roles/examples/revops.md +26 -0
- package/plugin/roles/examples/seo.md +27 -0
- package/plugin/roles/examples/specialist.md +24 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +252 -0
- package/plugin/scripts/capabilities.py +197 -0
- package/plugin/scripts/card.py +312 -0
- package/plugin/scripts/cities.py +520 -0
- package/plugin/scripts/city-env.sh +90 -0
- package/plugin/scripts/city-runtime.sh +87 -0
- package/plugin/scripts/city-session.sh +526 -0
- package/plugin/scripts/city_env.py +67 -0
- package/plugin/scripts/crecimiento.py +140 -0
- package/plugin/scripts/deliberations.py +48 -0
- package/plugin/scripts/doctor.py +172 -0
- package/plugin/scripts/domains.py +201 -0
- package/plugin/scripts/evidencia.py +64 -0
- package/plugin/scripts/find-repos.sh +111 -0
- package/plugin/scripts/gh.py +159 -0
- package/plugin/scripts/hall_protocol.py +147 -0
- package/plugin/scripts/hook_activity.py +139 -0
- package/plugin/scripts/launch.py +94 -0
- package/plugin/scripts/logs.py +98 -0
- package/plugin/scripts/pairing.py +185 -0
- package/plugin/scripts/parcels.py +132 -0
- package/plugin/scripts/read-card.py +51 -0
- package/plugin/scripts/report.py +201 -0
- package/plugin/scripts/reset.py +180 -0
- package/plugin/scripts/roads.py +230 -0
- package/plugin/scripts/roles.py +274 -0
- package/plugin/scripts/runtime_log.py +69 -0
- package/plugin/scripts/runtime_processes.py +132 -0
- package/plugin/scripts/rutas.py +83 -0
- package/plugin/scripts/seat.py +1349 -0
- package/plugin/scripts/tokens.py +194 -0
- package/plugin/scripts/trust-repos.py +59 -0
- package/plugin/scripts/ui.py +280 -0
- package/plugin/scripts/units.py +80 -0
- package/plugin/scripts/workspace.py +364 -0
- package/plugin/skills/city/SKILL.md +191 -0
- package/templates/blank.md +39 -0
- package/templates/finance.md +44 -0
- package/templates/legal.md +45 -0
- package/templates/marketing.md +48 -0
- package/templates/product.md +45 -0
|
@@ -0,0 +1,1349 @@
|
|
|
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 --repos choose your folders again
|
|
6
|
+
./bin/seat --agent-roles choose what role each repo agent adopts
|
|
7
|
+
./bin/seat --goal set your goal again
|
|
8
|
+
./bin/seat --engines what runs in each window: a model, or another CLI
|
|
9
|
+
./bin/seat --domain software change the city's work domain
|
|
10
|
+
./bin/seat --role dev set your role without being asked
|
|
11
|
+
./bin/seat --city home open one specific city
|
|
12
|
+
./bin/seat --no-yolo repo windows with confirmations on
|
|
13
|
+
./bin/seat --only a,b just these folders this time
|
|
14
|
+
|
|
15
|
+
Five questions the first time — the work domain, your chair role inside it, your
|
|
16
|
+
folders and each repo agent's role, one goal, and what runs in each window. Enter
|
|
17
|
+
keeps everything on your own Claude; a window can run a picked model, or another
|
|
18
|
+
CLI entirely — Codex, OpenCode, Kimi, or an explicit terminal fallback.
|
|
19
|
+
Then a tmux session: one window per folder you answer for, each with its assigned
|
|
20
|
+
operating role and agent already running, and a `seat` window holding the chair
|
|
21
|
+
role. Every repo runtime receives chaired assignments through one local bus and
|
|
22
|
+
never addresses another repo runtime directly.
|
|
23
|
+
|
|
24
|
+
It installs what it needs rather than telling you to: tmux, the city plugin, and
|
|
25
|
+
the separate GitHub CLI `gh` **only if** you choose to read your folders from
|
|
26
|
+
GitHub. `gh` is not bundled inside the npm package. That last one is a
|
|
27
|
+
choice on purpose — reading your disk asks nobody for anything and needs no
|
|
28
|
+
account, so it is the default, and installing a GitHub client and opening a login
|
|
29
|
+
flow at everybody would take that away.
|
|
30
|
+
|
|
31
|
+
None of this needs Cloudflare or an account. A user owns several autonomous
|
|
32
|
+
cities under ``~/.agents-city/<user>/<city>``; each has one seat, one role and
|
|
33
|
+
goal inside one work domain, its support repo agents and explicit roads to other
|
|
34
|
+
cities. Skills remain
|
|
35
|
+
installed in their repos and are discovered, never copied into the city.
|
|
36
|
+
|
|
37
|
+
Requires tmux and bash, so on Windows run it inside WSL.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import shutil
|
|
45
|
+
import subprocess
|
|
46
|
+
import sys
|
|
47
|
+
|
|
48
|
+
GUIONES = os.path.dirname(os.path.abspath(__file__))
|
|
49
|
+
sys.path.insert(0, GUIONES)
|
|
50
|
+
import card # noqa: E402 a card: read, written
|
|
51
|
+
import gh # noqa: E402 who you are, and what GitHub knows
|
|
52
|
+
import roles # noqa: E402 the catalogue and the bus suffixes
|
|
53
|
+
import domains # noqa: E402 work domain -> relevant role and knowledge packs
|
|
54
|
+
import ui # noqa: E402 the terminal widgets
|
|
55
|
+
import units # noqa: E402 the districts file, one writer
|
|
56
|
+
import cities # noqa: E402 which cities exist, and what each is called
|
|
57
|
+
import parcels # noqa: E402 the houses file, one writer
|
|
58
|
+
|
|
59
|
+
# Where the clone is, when there is one. The session script and the map live in it;
|
|
60
|
+
# neither is needed to open a seat, and a seat opened from an installed plugin has no
|
|
61
|
+
# clone at all.
|
|
62
|
+
RAIZ = os.path.dirname(os.path.dirname(GUIONES))
|
|
63
|
+
|
|
64
|
+
# Nothing about roles, identity or a card is decided in this file. It asks the
|
|
65
|
+
# questions; roles.py, gh.py and card.py hold the answers, and the wizard reads the
|
|
66
|
+
# same three. Every one of those started as a copy in here, and every copy drifted.
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Running things and finding programs is gh.py's job; a private copy here is how
|
|
70
|
+
# the two doors drift. The long timeout matters: find-repos.sh's first crawl of a
|
|
71
|
+
# big home directory is slow, and killing it half-way looks like an empty disk.
|
|
72
|
+
def sh(args, **kw):
|
|
73
|
+
return gh.sh(args, timeout=kw.pop("timeout", 300), **kw)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
hay = gh.hay
|
|
77
|
+
|
|
78
|
+
# One progress contract for the whole first-run flow. Nested source/clone screens
|
|
79
|
+
# remain inside the folders step rather than pretending they are extra decisions.
|
|
80
|
+
PASOS = 6
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def instala(programa, formulas):
|
|
84
|
+
"""Install one thing with whatever package manager is here. `formulas` maps a
|
|
85
|
+
manager to the package name, because they disagree (`gh` vs `github-cli`)."""
|
|
86
|
+
for gestor, plantilla in (
|
|
87
|
+
("brew", ["brew", "install", "{}"]),
|
|
88
|
+
("apt-get", ["sudo", "apt-get", "install", "-y", "{}"]),
|
|
89
|
+
("dnf", ["sudo", "dnf", "install", "-y", "{}"]),
|
|
90
|
+
("pacman", ["sudo", "pacman", "-S", "--noconfirm", "{}"]),
|
|
91
|
+
):
|
|
92
|
+
if gestor in formulas and hay(gestor):
|
|
93
|
+
orden = [p.format(formulas[gestor]) for p in plantilla]
|
|
94
|
+
print(f" Installing {programa}: {' '.join(orden)}\n")
|
|
95
|
+
subprocess.run(orden)
|
|
96
|
+
return hay(programa)
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def asegura_tmux():
|
|
101
|
+
"""tmux, installed if it is not there.
|
|
102
|
+
|
|
103
|
+
The whole promise is one command, and "first go and install tmux" is a second
|
|
104
|
+
one. So this installs it with whatever package manager the machine has, says so
|
|
105
|
+
out loud, and if there is none it prints the exact line to run rather than a
|
|
106
|
+
guess about what went wrong.
|
|
107
|
+
"""
|
|
108
|
+
if hay("tmux"):
|
|
109
|
+
return True
|
|
110
|
+
print(" tmux is not here, and the session is made of tmux windows.")
|
|
111
|
+
if instala("tmux", {"brew": "tmux", "apt-get": "tmux", "dnf": "tmux", "pacman": "tmux"}):
|
|
112
|
+
print("\n tmux installed.\n")
|
|
113
|
+
return True
|
|
114
|
+
print(
|
|
115
|
+
"\n I could not install it. Install tmux and run this again:\n"
|
|
116
|
+
" macOS: brew install tmux\n"
|
|
117
|
+
" Debian: sudo apt-get install tmux\n",
|
|
118
|
+
file=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def gh_conectado():
|
|
124
|
+
"""Whether `gh` is here AND logged in. Both, because an installed-but-anonymous
|
|
125
|
+
gh fails on the first list with a message about scopes that reads like a bug."""
|
|
126
|
+
return gh.conectado()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def asegura_gh():
|
|
130
|
+
"""The GitHub CLI, installed and logged in — only ever called because somebody
|
|
131
|
+
chose to read their repos from GitHub.
|
|
132
|
+
|
|
133
|
+
Deliberately not part of the default path. "No account needed" is the thing that
|
|
134
|
+
makes a seat work on its own, and installing a GitHub client and opening a login
|
|
135
|
+
flow at everybody is the opposite of that. Reading from your disk asks nobody
|
|
136
|
+
for anything, so it stays the default and this stays a choice.
|
|
137
|
+
"""
|
|
138
|
+
if gh_conectado():
|
|
139
|
+
return True
|
|
140
|
+
if not hay("gh"):
|
|
141
|
+
print(
|
|
142
|
+
"\n GitHub access uses the separate `gh` system CLI. It is not bundled "
|
|
143
|
+
"inside the npm package, and it is not installed unless you choose "
|
|
144
|
+
"GitHub here."
|
|
145
|
+
)
|
|
146
|
+
if not instala("gh", {"brew": "gh", "apt-get": "gh", "dnf": "gh", "pacman": "github-cli"}):
|
|
147
|
+
print(
|
|
148
|
+
" I could not install it. Either install it —\n"
|
|
149
|
+
" macOS: brew install gh\n"
|
|
150
|
+
" Debian: sudo apt-get install gh\n"
|
|
151
|
+
" — or pick your repos from this disk instead.\n",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
return False
|
|
155
|
+
if not gh_conectado():
|
|
156
|
+
print(
|
|
157
|
+
"\n Opening GitHub OAuth in your browser. The terminal also shows a one-time\n"
|
|
158
|
+
" device code, so the same flow works when the browser cannot open.\n"
|
|
159
|
+
)
|
|
160
|
+
# No capture: this one is a conversation, and swallowing its output would
|
|
161
|
+
# leave somebody staring at a frozen terminal waiting for a device code.
|
|
162
|
+
gh.autentica_web()
|
|
163
|
+
if not gh_conectado():
|
|
164
|
+
print("\n Still not logged in. Falling back to this disk.\n", file=sys.stderr)
|
|
165
|
+
return False
|
|
166
|
+
print()
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def asegura_plugin(usuario, agente, datos):
|
|
171
|
+
"""The city plugin, installed and pointed at your card.
|
|
172
|
+
|
|
173
|
+
Without it the seat window is a plain Claude session: no `/city:goals`, no
|
|
174
|
+
`/city:round`, no skills. With it, and with no bus deployed anywhere, it is
|
|
175
|
+
your role with its commands — which is exactly what level 2 should be.
|
|
176
|
+
|
|
177
|
+
The two bus fields stay empty on purpose. They are what makes you reachable by
|
|
178
|
+
somebody else's agent, and that is a different level; the plugin used to mark
|
|
179
|
+
them required, which meant a person working alone was nagged for credentials to
|
|
180
|
+
a bus that did not exist.
|
|
181
|
+
"""
|
|
182
|
+
if not hay("claude"):
|
|
183
|
+
print(" (claude is not on PATH, so I am skipping the plugin — the session still opens.)")
|
|
184
|
+
return
|
|
185
|
+
instalados = sh(["claude", "plugin", "list"])
|
|
186
|
+
if "city@agents-city" in instalados:
|
|
187
|
+
instalada = ""
|
|
188
|
+
m = re.search(r"city@agents-city.*?^\s*Version:\s*(\S+)", instalados, re.M | re.S)
|
|
189
|
+
if m:
|
|
190
|
+
instalada = m.group(1)
|
|
191
|
+
manifiestos = [
|
|
192
|
+
os.path.join(RAIZ, "plugin", ".claude-plugin", "plugin.json"),
|
|
193
|
+
os.path.join(os.path.dirname(GUIONES), ".claude-plugin", "plugin.json"),
|
|
194
|
+
]
|
|
195
|
+
deseada = ""
|
|
196
|
+
for manifiesto in manifiestos:
|
|
197
|
+
try:
|
|
198
|
+
deseada = str(json.load(open(manifiesto, encoding="utf-8")).get("version") or "")
|
|
199
|
+
except (OSError, ValueError, TypeError):
|
|
200
|
+
continue
|
|
201
|
+
if deseada:
|
|
202
|
+
break
|
|
203
|
+
# Older Claude versions did not print plugin versions. Silence remains
|
|
204
|
+
# safer than reinstalling on every seat launch when comparison is
|
|
205
|
+
# impossible. Current Claude updates only on an observed mismatch.
|
|
206
|
+
if not instalada or not deseada or instalada == deseada:
|
|
207
|
+
return
|
|
208
|
+
print(f" Updating the city plugin from {instalada} to {deseada}…")
|
|
209
|
+
actualiza = subprocess.run(
|
|
210
|
+
[
|
|
211
|
+
"claude",
|
|
212
|
+
"plugin",
|
|
213
|
+
"update",
|
|
214
|
+
"city@agents-city",
|
|
215
|
+
"--scope",
|
|
216
|
+
"user",
|
|
217
|
+
"--yes",
|
|
218
|
+
],
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
)
|
|
222
|
+
if actualiza.returncode == 0:
|
|
223
|
+
print(" · updated; the new city windows will load this version")
|
|
224
|
+
else:
|
|
225
|
+
print(" · the existing plugin remains enabled; its update did not complete")
|
|
226
|
+
return
|
|
227
|
+
print(" Installing the city plugin, so your seat has its commands…")
|
|
228
|
+
if "agents-city" not in sh(["claude", "plugin", "marketplace", "list"]):
|
|
229
|
+
subprocess.run(
|
|
230
|
+
["claude", "plugin", "marketplace", "add", RAIZ], capture_output=True, text=True
|
|
231
|
+
)
|
|
232
|
+
# No --config here on purpose. It writes into the user's global settings.json,
|
|
233
|
+
# and it only ever runs on a fresh install — so a plugin somebody installed by
|
|
234
|
+
# hand first would never get configured, which is how this was wrong a moment
|
|
235
|
+
# ago. The session passes AGENTS_CITY_DATA and CITY_BUS_AGENT in the
|
|
236
|
+
# environment instead, and city_env.py reads the environment before anything
|
|
237
|
+
# else. Same answer, nothing written to a file you did not ask us to touch.
|
|
238
|
+
r = subprocess.run(
|
|
239
|
+
["claude", "plugin", "install", "city@agents-city", "-y"], capture_output=True, text=True
|
|
240
|
+
)
|
|
241
|
+
if "Successfully installed" in r.stdout or "already installed" in r.stdout:
|
|
242
|
+
print(
|
|
243
|
+
f" · installed. Your seat is {agente}, reading "
|
|
244
|
+
f"{datos.replace(os.path.expanduser('~'), '~')}"
|
|
245
|
+
)
|
|
246
|
+
print(" · no bus token needed — local roads work without one")
|
|
247
|
+
else:
|
|
248
|
+
print(" · could not install it; the session still opens without it.")
|
|
249
|
+
if r.stderr.strip():
|
|
250
|
+
print(f" {r.stderr.strip().splitlines()[0]}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def quien_soy():
|
|
254
|
+
"""Your git email, minus the domain. Wrong for maybe one person in twenty, and
|
|
255
|
+
for them the argument exists — which beats asking twenty people a question
|
|
256
|
+
nineteen already answered by configuring git.
|
|
257
|
+
|
|
258
|
+
The email-to-username rule lives in gh.py and is imported, not repeated. It was
|
|
259
|
+
repeated once, and the two copies disagreed: this one turned
|
|
260
|
+
`12345678+alice@…` into `12345678alice` while the wizard turned it into `alice`,
|
|
261
|
+
so the same person got two different cards depending on the door.
|
|
262
|
+
"""
|
|
263
|
+
if os.environ.get("AGENTS_CITY_USER"):
|
|
264
|
+
return gh.usuario_de_correo(os.environ["AGENTS_CITY_USER"])
|
|
265
|
+
correo = sh(["git", "config", "user.email"]).strip()
|
|
266
|
+
return gh.usuario_de_correo(correo) or os.environ.get("USER", "me")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def donde_viven_las_fichas(usuario=""):
|
|
270
|
+
"""The selected city, through the one resolver every door shares."""
|
|
271
|
+
return cities.actual(usuario or quien_soy())
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def repos_del_disco():
|
|
275
|
+
"""Every repo cloned on this machine, by the name on its remote.
|
|
276
|
+
|
|
277
|
+
find-repos.sh already does this and caches it for a day, which matters: the
|
|
278
|
+
first crawl of a full home directory is not fast, and nobody wants to wait for
|
|
279
|
+
it twice.
|
|
280
|
+
"""
|
|
281
|
+
salida = sh([os.path.join(GUIONES, "find-repos.sh")])
|
|
282
|
+
fuera = []
|
|
283
|
+
for linea in salida.splitlines():
|
|
284
|
+
if "\t" in linea:
|
|
285
|
+
nombre, ruta = linea.split("\t", 1)
|
|
286
|
+
fuera.append((nombre.strip(), ruta.strip()))
|
|
287
|
+
return fuera
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def ultimo_commit(ruta):
|
|
291
|
+
return sh(["git", "-C", ruta, "log", "-1", "--format=%ad", "--date=short"]).strip()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def mios(usuario, correo, repos):
|
|
295
|
+
"""Which of these you have actually committed to in the last year. The picker
|
|
296
|
+
starts with these ticked, so the common case is look-and-confirm rather than
|
|
297
|
+
hunting your own repos out of a list of two hundred."""
|
|
298
|
+
marcados = set()
|
|
299
|
+
for nombre, ruta in repos:
|
|
300
|
+
autores = sh(["git", "-C", ruta, "log", "--since=1.year", "--format=%ae"])
|
|
301
|
+
if any(
|
|
302
|
+
a.split("@")[0].strip().lower() in (usuario, correo.split("@")[0].lower())
|
|
303
|
+
for a in autores.splitlines()
|
|
304
|
+
if a.strip()
|
|
305
|
+
):
|
|
306
|
+
marcados.add(nombre)
|
|
307
|
+
return marcados
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def escribe_ficha(ruta, usuario, rol, repos, objetivo=None, ciudad="", repo_roles=None):
|
|
311
|
+
"""The card. Everything the seat, a round and the map read about a person.
|
|
312
|
+
|
|
313
|
+
The goal block comes from card.py, which the wizard also uses — a round reads
|
|
314
|
+
whichever door produced the card and must not be able to tell.
|
|
315
|
+
"""
|
|
316
|
+
agente = roles.agente(usuario, rol, ciudad)
|
|
317
|
+
asignados = card.normaliza_roles_repos(repos, repo_roles)
|
|
318
|
+
fm = [
|
|
319
|
+
"---",
|
|
320
|
+
f"user: {usuario}",
|
|
321
|
+
f"name: {usuario}",
|
|
322
|
+
f"role: {rol}",
|
|
323
|
+
f"agent: {agente}",
|
|
324
|
+
"repos: [" + ", ".join(sorted(repos)) + "]",
|
|
325
|
+
*(f"role.{card.ventana(repo)}: {rol_repo}" for repo, rol_repo in asignados.items()),
|
|
326
|
+
f"goals_defined: {'true' if objetivo else 'false'}",
|
|
327
|
+
"---",
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
oficio = roles.oficio(rol, os.path.dirname(ruta))
|
|
331
|
+
cuerpo = [
|
|
332
|
+
"",
|
|
333
|
+
f"# {usuario}",
|
|
334
|
+
"",
|
|
335
|
+
card.linea_rol(rol, oficio),
|
|
336
|
+
"",
|
|
337
|
+
"## Repos",
|
|
338
|
+
"",
|
|
339
|
+
]
|
|
340
|
+
if repos:
|
|
341
|
+
cuerpo += [
|
|
342
|
+
"Answers for: " + ", ".join(f"`{r}`" for r in sorted(repos)) + ".",
|
|
343
|
+
"",
|
|
344
|
+
"Change them with `./bin/seat --repos`.",
|
|
345
|
+
]
|
|
346
|
+
else:
|
|
347
|
+
cuerpo += [
|
|
348
|
+
"Owns no folders, on purpose. This role holds a property of "
|
|
349
|
+
"everybody else's — so it has no houses, and what it is for "
|
|
350
|
+
"begins where there is more than one city to reach.",
|
|
351
|
+
"",
|
|
352
|
+
"If that is wrong: `./bin/seat --repos`.",
|
|
353
|
+
]
|
|
354
|
+
cuerpo += ["", "## Current goals", ""]
|
|
355
|
+
|
|
356
|
+
cuerpo += card.bloque_objetivo(objetivo)
|
|
357
|
+
if objetivo:
|
|
358
|
+
cuerpo += ["_Set when this seat was opened._", ""]
|
|
359
|
+
|
|
360
|
+
cuerpo += ["## Round history", "", "Rounds leave their summary here, most recent first.", ""]
|
|
361
|
+
open(ruta, "w", encoding="utf-8").write("\n".join(fm + cuerpo))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def pregunta_dominio(actual=""):
|
|
365
|
+
"""The city's work context, before asking which responsibility the seat has."""
|
|
366
|
+
packs = domains.catalogo()
|
|
367
|
+
elegido = ui.una(
|
|
368
|
+
"What domain does this city work in?",
|
|
369
|
+
[(p["id"], p["name"], p["summary"]) for p in packs],
|
|
370
|
+
1,
|
|
371
|
+
PASOS,
|
|
372
|
+
ayuda="The domain chooses the vocabulary, evidence standard and relevant "
|
|
373
|
+
"roles. Its knowledge pack is copied into the city as editable "
|
|
374
|
+
"Markdown. Repo skills remain in their repos.",
|
|
375
|
+
)
|
|
376
|
+
return domains.canonico(elegido) if elegido else None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def pregunta_rol(dominio="software", actual=""):
|
|
380
|
+
"""Which role inside the selected domain. Asked of everybody, always.
|
|
381
|
+
|
|
382
|
+
It used to be asked only of somebody who picked no repos, and defaulted to `dev`
|
|
383
|
+
otherwise — which put every architect, surveyor and designer on the bus as
|
|
384
|
+
`<user>/dev`. The role is what the seat window *is*: it decides what reaches you
|
|
385
|
+
and the name you answer to. It is not a thing to infer.
|
|
386
|
+
"""
|
|
387
|
+
pack = domains.obtiene(dominio) or domains.obtiene("software")
|
|
388
|
+
catalogo = {r: (n, d, transversal) for r, n, d, transversal in roles.CATALOGO}
|
|
389
|
+
ops = []
|
|
390
|
+
for rol, _ in pack["roles"]:
|
|
391
|
+
nombre, descripcion, transversal = catalogo.get(
|
|
392
|
+
rol, (roles.nombre(rol), "", roles.transversal(rol))
|
|
393
|
+
)
|
|
394
|
+
if not transversal:
|
|
395
|
+
descripcion += " · owns workspaces and answers for them"
|
|
396
|
+
ops.append((rol, nombre, descripcion))
|
|
397
|
+
return ui.una(
|
|
398
|
+
f"Which role is yours in {pack['name']}?",
|
|
399
|
+
ops,
|
|
400
|
+
2,
|
|
401
|
+
PASOS,
|
|
402
|
+
ayuda="The domain says what kind of work this is. Your role says what the "
|
|
403
|
+
"owner seat is accountable for: what it asks support agents and what "
|
|
404
|
+
"must reach it. The seat remains the chair even if you choose blank. "
|
|
405
|
+
"Owning no folders is valid for a cross-cutting role.",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _opciones_rol(roles_pack, actual="", dominio_extra=""):
|
|
410
|
+
"""Picker rows for role ids, with the current choice first when present."""
|
|
411
|
+
catalogo = {r: (n, d) for r, n, d, _ in roles.CATALOGO}
|
|
412
|
+
ids = list(dict.fromkeys(r for r, _ in roles_pack))
|
|
413
|
+
if actual and actual not in ids:
|
|
414
|
+
ids.insert(0, actual)
|
|
415
|
+
elif actual in ids:
|
|
416
|
+
ids.remove(actual)
|
|
417
|
+
ids.insert(0, actual)
|
|
418
|
+
fuera = []
|
|
419
|
+
for rol in ids:
|
|
420
|
+
nombre, descripcion = catalogo.get(rol, (roles.nombre(rol), "custom role"))
|
|
421
|
+
sufijo = f" · {dominio_extra}" if dominio_extra else ""
|
|
422
|
+
if rol == actual:
|
|
423
|
+
sufijo += " · current"
|
|
424
|
+
fuera.append((rol, nombre, descripcion + sufijo))
|
|
425
|
+
return fuera
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def pregunta_rol_agente(repo, dominio, actual="blank"):
|
|
429
|
+
"""Choose one repo agent's operating role without confusing it with authority.
|
|
430
|
+
|
|
431
|
+
Roles from the city domain are one short list. ``another-domain`` opens the
|
|
432
|
+
other built-in packs, so a software city can deliberately give a portfolio
|
|
433
|
+
repo an SEO role. The seat remains the only chair either way.
|
|
434
|
+
"""
|
|
435
|
+
pack = domains.obtiene(dominio) or domains.obtiene("software")
|
|
436
|
+
actual = card.rol_seguro(actual)
|
|
437
|
+
ops = _opciones_rol(pack["roles"], actual)
|
|
438
|
+
ops.append(
|
|
439
|
+
(
|
|
440
|
+
"another-domain",
|
|
441
|
+
"Role from another domain…",
|
|
442
|
+
"marketing, finance, legal, medicine, research, sales or operations",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
elegido = ui.una(
|
|
446
|
+
f"What role should the {repo} agent adopt?",
|
|
447
|
+
ops,
|
|
448
|
+
3,
|
|
449
|
+
PASOS,
|
|
450
|
+
ayuda="This is the repo agent's specialty, not authority. The city seat "
|
|
451
|
+
"remains its chair. Blank is an explicit role with no preset knowledge.",
|
|
452
|
+
)
|
|
453
|
+
if elegido is None:
|
|
454
|
+
return None
|
|
455
|
+
if elegido != "another-domain":
|
|
456
|
+
return elegido
|
|
457
|
+
|
|
458
|
+
packs = domains.catalogo()
|
|
459
|
+
otro = ui.una(
|
|
460
|
+
f"Which role family fits the {repo} agent?",
|
|
461
|
+
[(p["id"], p["name"], p["summary"]) for p in packs],
|
|
462
|
+
3,
|
|
463
|
+
PASOS,
|
|
464
|
+
ayuda="The repo stays in this city. This only chooses the specialist "
|
|
465
|
+
"vocabulary and role knowledge its agent adopts.",
|
|
466
|
+
)
|
|
467
|
+
if otro is None:
|
|
468
|
+
return None
|
|
469
|
+
elegido_pack = domains.obtiene(otro)
|
|
470
|
+
return ui.una(
|
|
471
|
+
f"What role should the {repo} agent adopt?",
|
|
472
|
+
_opciones_rol(elegido_pack["roles"], "", elegido_pack["name"]),
|
|
473
|
+
3,
|
|
474
|
+
PASOS,
|
|
475
|
+
ayuda="The seat remains the boss and the only router. This role controls "
|
|
476
|
+
"the perspective and evidence expected from this repo agent.",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def pregunta_roles_repos(repos, dominio, actuales=None):
|
|
481
|
+
"""One explicit operating role per concrete support agent."""
|
|
482
|
+
actuales = dict(actuales or {})
|
|
483
|
+
fuera = {}
|
|
484
|
+
for repo in sorted(repos):
|
|
485
|
+
elegido = pregunta_rol_agente(repo, dominio, actuales.get(repo, "blank"))
|
|
486
|
+
if elegido is None:
|
|
487
|
+
return None
|
|
488
|
+
fuera[repo] = elegido
|
|
489
|
+
return fuera
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def pregunta_objetivo(usuario):
|
|
493
|
+
"""One goal, with the command that measures it. Skippable, and it says so.
|
|
494
|
+
|
|
495
|
+
Not optional decoration: a round with no goal degrades into a status report,
|
|
496
|
+
because there is nothing to contrast the work against. But refusing to open a
|
|
497
|
+
seat until somebody has invented a metric is worse, so an empty title skips it.
|
|
498
|
+
"""
|
|
499
|
+
if not ui.pantalla(
|
|
500
|
+
"One goal, and the command that measures it",
|
|
501
|
+
"This is the part that does not change. Whatever your work is made of, "
|
|
502
|
+
"what holds it together is one goal, with the command that measures it.\n\n"
|
|
503
|
+
'A goal you cannot measure is not a goal here. "Improve performance" '
|
|
504
|
+
'cannot be accepted or refused with any judgement; "the p95 drops from '
|
|
505
|
+
'800 ms to 400 ms" can. And the measure is written, not described: if '
|
|
506
|
+
"the signal can be pulled with a command, that command is part of the "
|
|
507
|
+
"goal.\n\n"
|
|
508
|
+
"Skip it and your seat still opens. It just cannot tell you whether "
|
|
509
|
+
"anything is going well.",
|
|
510
|
+
4,
|
|
511
|
+
PASOS,
|
|
512
|
+
"enter to set one · q to skip",
|
|
513
|
+
):
|
|
514
|
+
return None
|
|
515
|
+
titulo = ui.pide(
|
|
516
|
+
"The goal, in one line",
|
|
517
|
+
"",
|
|
518
|
+
ayuda="What has to be achieved. Concrete enough to argue with. "
|
|
519
|
+
"Empty skips the goal entirely — set it any time with "
|
|
520
|
+
"./bin/seat --goal.",
|
|
521
|
+
)
|
|
522
|
+
if not titulo:
|
|
523
|
+
return None
|
|
524
|
+
# A measure is a command that returns a number, or a person's judgement written
|
|
525
|
+
# down. The second is a first-class answer, not a fallback: "quality of the
|
|
526
|
+
# AGENTS.md files, read by the architect on Fridays" is a real goal with no
|
|
527
|
+
# number anywhere. Every question from here on takes empty as "to be defined".
|
|
528
|
+
signal = (
|
|
529
|
+
ui.pide(
|
|
530
|
+
"How is it measured",
|
|
531
|
+
"",
|
|
532
|
+
ayuda="The signal that says whether it is going well. It does "
|
|
533
|
+
"not have to be a number — prose is a real answer. "
|
|
534
|
+
"Empty = to be defined.",
|
|
535
|
+
)
|
|
536
|
+
or ""
|
|
537
|
+
)
|
|
538
|
+
comando = (
|
|
539
|
+
ui.pide(
|
|
540
|
+
"The command that returns it, if a command can",
|
|
541
|
+
"",
|
|
542
|
+
ayuda="Runnable as written. Leave it EMPTY for a qualitative "
|
|
543
|
+
"goal — the next question asks who judges it instead.",
|
|
544
|
+
)
|
|
545
|
+
or ""
|
|
546
|
+
)
|
|
547
|
+
manual = ""
|
|
548
|
+
if not comando:
|
|
549
|
+
manual = (
|
|
550
|
+
ui.pide(
|
|
551
|
+
"Who judges it, and how often",
|
|
552
|
+
"",
|
|
553
|
+
ayuda='In prose: "the architect reads the AGENTS.md files '
|
|
554
|
+
'on Fridays". Empty = to be decided.',
|
|
555
|
+
)
|
|
556
|
+
or ""
|
|
557
|
+
)
|
|
558
|
+
return {
|
|
559
|
+
"user": usuario,
|
|
560
|
+
"title": titulo,
|
|
561
|
+
"signal": signal,
|
|
562
|
+
"command": comando,
|
|
563
|
+
"manual": manual,
|
|
564
|
+
"baseline": ui.pide(
|
|
565
|
+
"Where it stands today",
|
|
566
|
+
"",
|
|
567
|
+
ayuda="Run the command, or say it in words. Empty = not measured yet.",
|
|
568
|
+
)
|
|
569
|
+
or "",
|
|
570
|
+
"target": ui.pide("Where it has to get to", "", ayuda="Empty = to be defined.") or "",
|
|
571
|
+
"by": ui.pide("By when", "this quarter") or "this quarter",
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# The agent CLIs a window can run instead of Claude, offered when they are on the
|
|
576
|
+
# PATH. Unknown CLIs use the visibly labelled terminal compatibility fallback;
|
|
577
|
+
# a local model can still arrive through a native OpenCode command pointed at an
|
|
578
|
+
# OpenAI-compatible endpoint such as LM Studio or Ollama.
|
|
579
|
+
OTROS_MOTORES = ("codex", "opencode", "kimi")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def motor_de_ventana(win):
|
|
583
|
+
"""One window's engine. Returns the card keys to write for it — empty values
|
|
584
|
+
clear a key back to the default — or None when the question was quit."""
|
|
585
|
+
ops = [
|
|
586
|
+
(
|
|
587
|
+
"claude",
|
|
588
|
+
"Claude",
|
|
589
|
+
"the /city: commands live here"
|
|
590
|
+
if win == "seat"
|
|
591
|
+
else "your default, or pick a model next",
|
|
592
|
+
)
|
|
593
|
+
]
|
|
594
|
+
ops += [(m, m, "on this machine") for m in OTROS_MOTORES if shutil.which(m)]
|
|
595
|
+
ops.append(
|
|
596
|
+
(
|
|
597
|
+
"otro",
|
|
598
|
+
"another command (terminal fallback)",
|
|
599
|
+
"verbatim, flags included — explicit compatibility mode; "
|
|
600
|
+
"known runtimes never use terminal injection",
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
ayuda = (
|
|
604
|
+
"Claude reads the model from the card; any other CLI is launched "
|
|
605
|
+
"exactly as written, so its model goes in its own flags. Change it "
|
|
606
|
+
"any time: ./bin/seat --engines"
|
|
607
|
+
)
|
|
608
|
+
if win == "seat":
|
|
609
|
+
ayuda = (
|
|
610
|
+
"This is your own window, the one that holds your role. On Claude "
|
|
611
|
+
"it also carries the `/city:` commands and the plugin; on anything "
|
|
612
|
+
"else you keep your folder and your identity, and the same jobs "
|
|
613
|
+
"are done from the terminal (`agents-city seat --goal`, `--engines`, "
|
|
614
|
+
"`exit`). Pick another CLI here and the city has no Claude in it "
|
|
615
|
+
"at all — which is a real answer."
|
|
616
|
+
)
|
|
617
|
+
motor = ui.una(f"The `{win}` window runs…", ops, ayuda=ayuda)
|
|
618
|
+
if motor is None:
|
|
619
|
+
return None
|
|
620
|
+
if motor == "claude":
|
|
621
|
+
modelo = ui.pide("Model for this window (empty = your default)", "") or ""
|
|
622
|
+
esfuerzo = ""
|
|
623
|
+
if modelo:
|
|
624
|
+
esfuerzo = ui.pide("Effort (low…max, empty = default)", "") or ""
|
|
625
|
+
return {f"runs.{win}": "", f"model.{win}": modelo, f"effort.{win}": esfuerzo}
|
|
626
|
+
orden = (
|
|
627
|
+
ui.pide(
|
|
628
|
+
"The exact command",
|
|
629
|
+
"" if motor == "otro" else str(motor),
|
|
630
|
+
ayuda="Launched verbatim in the window, flags included.",
|
|
631
|
+
)
|
|
632
|
+
or ""
|
|
633
|
+
)
|
|
634
|
+
if not orden:
|
|
635
|
+
return {}
|
|
636
|
+
if motor == "otro" and not orden.startswith("terminal:"):
|
|
637
|
+
orden = "terminal:" + orden
|
|
638
|
+
return {f"runs.{win}": orden, f"model.{win}": "", f"effort.{win}": ""}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def pregunta_motores(elegidos):
|
|
642
|
+
"""What starts in each window. Enter keeps every window on the person's own
|
|
643
|
+
Claude default, which is the right silence — no keys land on the card.
|
|
644
|
+
|
|
645
|
+
Returns None on a quit, {} for "leave everything as it is", or card keys to
|
|
646
|
+
write. Choosing Claude for a window CLEARS its `runs.`/`model.`/`effort.`
|
|
647
|
+
keys, so coming back from codex to plain Claude is the same gesture as
|
|
648
|
+
leaving; quitting one window's question skips that window untouched.
|
|
649
|
+
|
|
650
|
+
The seat's own window is asked about first, and it is asked even when you own
|
|
651
|
+
no folders: there is always a seat, and a person who wants no Claude anywhere
|
|
652
|
+
has to be able to say so.
|
|
653
|
+
"""
|
|
654
|
+
eleccion = ui.una(
|
|
655
|
+
"What runs in each window?",
|
|
656
|
+
[
|
|
657
|
+
(
|
|
658
|
+
"claude",
|
|
659
|
+
"Claude, as it is now",
|
|
660
|
+
"every window starts your own Claude — nothing written",
|
|
661
|
+
),
|
|
662
|
+
(
|
|
663
|
+
"elegir",
|
|
664
|
+
"Pick per window",
|
|
665
|
+
"a model per window, or another CLI: Codex, OpenCode, Kimi, terminal fallback",
|
|
666
|
+
),
|
|
667
|
+
],
|
|
668
|
+
5,
|
|
669
|
+
PASOS,
|
|
670
|
+
ayuda="Each folder gets a window with an agent in it. This decides which "
|
|
671
|
+
"agent — and it is the skippable question: enter, and everything "
|
|
672
|
+
"runs your Claude.",
|
|
673
|
+
)
|
|
674
|
+
if eleccion is None:
|
|
675
|
+
return None
|
|
676
|
+
if eleccion != "elegir":
|
|
677
|
+
# 'claude' — and anything a script answers that is not a real choice.
|
|
678
|
+
return {}
|
|
679
|
+
cambios = {}
|
|
680
|
+
for win in ["seat"] + [card.ventana(r) for r in sorted(elegidos)]:
|
|
681
|
+
cambios.update(motor_de_ventana(win) or {})
|
|
682
|
+
return cambios
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def escribe_suelo(datos, repos, dominio="software"):
|
|
686
|
+
"""The least a map needs to draw: districts, and a house per repo.
|
|
687
|
+
|
|
688
|
+
Written only into our own folder and only when it is not there — a real data
|
|
689
|
+
repo's units.yml is somebody's modelling and this must never sit on top of it.
|
|
690
|
+
|
|
691
|
+
Why bother at all: "my city is my folders" should be true straight after
|
|
692
|
+
`./bin/seat`, with no wizard. The seeder degrades to an empty list when these
|
|
693
|
+
files are missing, and an empty list of districts is a map with no ground, so
|
|
694
|
+
the honest minimum is one district holding everything. Splitting repos into
|
|
695
|
+
parcels across real business units is the modelling nothing can do for you —
|
|
696
|
+
but it is an improvement on this, not a prerequisite for it.
|
|
697
|
+
"""
|
|
698
|
+
# No folders, no ground. Writing a district called "Mine" holding nothing is a
|
|
699
|
+
# lie about the shape of somebody's work, and under this model an empty city is
|
|
700
|
+
# a real answer rather than a half-finished one: a cross-cutting role lives on
|
|
701
|
+
# the roads between cities, and the roads are the bus.
|
|
702
|
+
# Domain belongs to the city even when it deliberately has no support repos.
|
|
703
|
+
# ``selecciona`` validates and writes only that scalar.
|
|
704
|
+
domains.selecciona(datos, dominio)
|
|
705
|
+
if not repos:
|
|
706
|
+
return []
|
|
707
|
+
|
|
708
|
+
hechos = []
|
|
709
|
+
u = os.path.join(datos, "units.yml")
|
|
710
|
+
if not os.path.exists(u):
|
|
711
|
+
# Written by the module that owns the format — the same one the hall's
|
|
712
|
+
# editor and the seeder read. This used to be a third inline copy.
|
|
713
|
+
units.escribe(u, [{"id": "mine", "name": "Mine", "color": "3fb8a0"}])
|
|
714
|
+
hechos.append('units.yml — one district, "Mine"')
|
|
715
|
+
|
|
716
|
+
p = os.path.join(datos, "parcels.yml")
|
|
717
|
+
if not os.path.exists(p):
|
|
718
|
+
parcels.escribe(p, {r: [{"ruta": "", "unidad": "mine", "nombre": r}] for r in repos})
|
|
719
|
+
hechos.append(
|
|
720
|
+
f"parcels.yml — {len(repos)} {'house' if len(repos) == 1 else 'houses'}, one per repo"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
c = os.path.join(datos, "city.yml")
|
|
724
|
+
try:
|
|
725
|
+
texto = open(c, encoding="utf-8").read()
|
|
726
|
+
except OSError:
|
|
727
|
+
texto = ""
|
|
728
|
+
faltan = []
|
|
729
|
+
pack = domains.obtiene(dominio) or domains.obtiene("software")
|
|
730
|
+
if not card.campo("---\n" + texto + "---\n", "grow_command") and pack.get("grow_cmd"):
|
|
731
|
+
faltan.append(f"grow_command: {pack['grow_cmd']}")
|
|
732
|
+
if faltan:
|
|
733
|
+
if texto and not texto.endswith("\n"):
|
|
734
|
+
texto += "\n"
|
|
735
|
+
cities._atomico(c, texto + "\n".join(faltan) + "\n")
|
|
736
|
+
hechos.append("city.yml — domain-specific growth command")
|
|
737
|
+
return hechos
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def repos_de_ficha(ruta):
|
|
741
|
+
"""The folders on a card. Was a subprocess to read-card.py, which is the same
|
|
742
|
+
parser this now imports — one process instead of two, and one parser."""
|
|
743
|
+
return card.lee(ruta).get("repos", [])
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def elige_fuente():
|
|
747
|
+
"""Where the list of folders comes from. Disk first, and GitHub as a choice.
|
|
748
|
+
|
|
749
|
+
Order is the argument: reading your disk asks nobody for anything and works with
|
|
750
|
+
no account, so it is the default. GitHub is offered second because it is better
|
|
751
|
+
when your work is not all cloned yet — and picking it is what triggers installing
|
|
752
|
+
and logging in `gh`, rather than doing that to everybody up front.
|
|
753
|
+
"""
|
|
754
|
+
if gh_conectado():
|
|
755
|
+
github_estado = "GitHub CLI ready and authenticated"
|
|
756
|
+
elif hay("gh"):
|
|
757
|
+
github_estado = "GitHub CLI installed · opens browser OAuth"
|
|
758
|
+
else:
|
|
759
|
+
github_estado = "installs the separate GitHub CLI · opens browser OAuth"
|
|
760
|
+
return ui.una(
|
|
761
|
+
"Where should I look for your folders?",
|
|
762
|
+
[
|
|
763
|
+
(
|
|
764
|
+
"disco",
|
|
765
|
+
"On this machine",
|
|
766
|
+
"every repo already cloned · no account, nothing installed",
|
|
767
|
+
),
|
|
768
|
+
("mia", "My GitHub account", f"your own repos, cloned or not · {github_estado}"),
|
|
769
|
+
("org", "A GitHub organisation", f"work you share with other people · {github_estado}"),
|
|
770
|
+
],
|
|
771
|
+
3,
|
|
772
|
+
PASOS,
|
|
773
|
+
ayuda="You can come back and change this with ./bin/seat --repos. "
|
|
774
|
+
"Anything you pick that is not cloned here, I offer to clone.",
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def repos_de_github(dueno, locales):
|
|
779
|
+
"""A GitHub owner's repos, each marked with where it is on this disk or that it
|
|
780
|
+
is not here at all. A repo that is not cloned can still go on your card — it
|
|
781
|
+
just gets no window until it is, and the session says which ones it could not
|
|
782
|
+
find rather than quietly opening fewer windows."""
|
|
783
|
+
fuera = []
|
|
784
|
+
for nombre, descripcion, empujado in gh.repos(dueno):
|
|
785
|
+
ruta = locales.get(nombre, "")
|
|
786
|
+
fuera.append((nombre, ruta, empujado, descripcion or ("" if ruta else "not cloned here")))
|
|
787
|
+
return fuera
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def clona(dueno, faltan, destino):
|
|
791
|
+
"""Clone what somebody picked and does not have. Asked, never assumed: it is
|
|
792
|
+
network and disk in somebody else's home directory."""
|
|
793
|
+
if not faltan:
|
|
794
|
+
return {}
|
|
795
|
+
corto = destino.replace(os.path.expanduser("~"), "~")
|
|
796
|
+
if not ui.pantalla(
|
|
797
|
+
f"{len(faltan)} of those are not on this machine",
|
|
798
|
+
f"{', '.join(faltan[:8])}{' …' if len(faltan) > 8 else ''}\n\n"
|
|
799
|
+
f"They can stay on your card either way — a folder you do not have just "
|
|
800
|
+
f"gets no window, and the session tells you which ones it could not "
|
|
801
|
+
f"find.\n\n"
|
|
802
|
+
f"Or I clone them into {corto} now, and they all get one.",
|
|
803
|
+
3,
|
|
804
|
+
PASOS,
|
|
805
|
+
"enter to clone them · q to leave them on the card",
|
|
806
|
+
):
|
|
807
|
+
return {}
|
|
808
|
+
os.makedirs(destino, exist_ok=True)
|
|
809
|
+
puestas = {}
|
|
810
|
+
for nombre in faltan:
|
|
811
|
+
ruta = os.path.join(destino, nombre)
|
|
812
|
+
if os.path.isdir(ruta):
|
|
813
|
+
puestas[nombre] = ruta
|
|
814
|
+
continue
|
|
815
|
+
print(f" cloning {dueno}/{nombre}…")
|
|
816
|
+
r = subprocess.run(
|
|
817
|
+
["gh", "repo", "clone", f"{dueno}/{nombre}", ruta], capture_output=True, text=True
|
|
818
|
+
)
|
|
819
|
+
if os.path.isdir(ruta):
|
|
820
|
+
puestas[nombre] = ruta
|
|
821
|
+
else:
|
|
822
|
+
ultima = (r.stderr or "").strip().splitlines()
|
|
823
|
+
print(f" could not: {ultima[-1] if ultima else 'unknown reason'}")
|
|
824
|
+
# The index is cached for a day and has just gone stale by definition.
|
|
825
|
+
subprocess.run([os.path.join(GUIONES, "find-repos.sh"), "--refresh"], capture_output=True)
|
|
826
|
+
return puestas
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def resuelve_fuente():
|
|
830
|
+
"""Where the folder list comes from, GitHub resolved or fallen back from.
|
|
831
|
+
|
|
832
|
+
GitHub is a choice, and choosing it is what installs and logs in gh. If any of
|
|
833
|
+
that does not work out, fall back to the disk rather than dead-ending: the disk
|
|
834
|
+
always has an answer. Returns (fuente, dueno) or None on a quit."""
|
|
835
|
+
fuente = elige_fuente()
|
|
836
|
+
if fuente is None:
|
|
837
|
+
return None
|
|
838
|
+
dueno = ""
|
|
839
|
+
if fuente in ("mia", "org"):
|
|
840
|
+
if not asegura_gh():
|
|
841
|
+
fuente = "disco"
|
|
842
|
+
elif fuente == "mia":
|
|
843
|
+
dueno = sh(["gh", "api", "user", "--jq", ".login"]).strip()
|
|
844
|
+
if not dueno:
|
|
845
|
+
fuente = "disco"
|
|
846
|
+
else:
|
|
847
|
+
orgs = gh.orgs()
|
|
848
|
+
if not orgs:
|
|
849
|
+
ui.pantalla(
|
|
850
|
+
"No organisations",
|
|
851
|
+
"Your account does not belong to any organisation gh can "
|
|
852
|
+
"see, or the token is missing the read:org scope.\n\n"
|
|
853
|
+
"Reading from this disk instead.",
|
|
854
|
+
3,
|
|
855
|
+
PASOS,
|
|
856
|
+
)
|
|
857
|
+
fuente = "disco"
|
|
858
|
+
else:
|
|
859
|
+
dueno = ui.una("Which organisation?", [(o, o, "") for o in orgs], 3, PASOS)
|
|
860
|
+
if dueno is None:
|
|
861
|
+
return None
|
|
862
|
+
return fuente, dueno
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def elige_repos(usuario, ya_marcados):
|
|
866
|
+
"""Which folders are yours: where to read the list from, then which ones."""
|
|
867
|
+
locales = dict(repos_del_disco())
|
|
868
|
+
correo = sh(["git", "config", "user.email"]).strip()
|
|
869
|
+
|
|
870
|
+
r = resuelve_fuente()
|
|
871
|
+
if r is None:
|
|
872
|
+
return None
|
|
873
|
+
fuente, dueno = r
|
|
874
|
+
|
|
875
|
+
if fuente == "disco":
|
|
876
|
+
if not locales:
|
|
877
|
+
print(
|
|
878
|
+
"\n I found no git repos on this machine.\n"
|
|
879
|
+
" Clone something first, point the search somewhere else —\n"
|
|
880
|
+
" CITY_SEARCH_IN=/where/they/are ./bin/seat\n"
|
|
881
|
+
" — or run ./bin/seat --repos and read them from GitHub.\n",
|
|
882
|
+
file=sys.stderr,
|
|
883
|
+
)
|
|
884
|
+
return None
|
|
885
|
+
filas = [(n, r, ultimo_commit(r), "") for n, r in locales.items()]
|
|
886
|
+
else:
|
|
887
|
+
print(f" Asking GitHub for the repos of {dueno}…")
|
|
888
|
+
filas = repos_de_github(dueno, locales)
|
|
889
|
+
if not filas:
|
|
890
|
+
print(f"\n gh returned nothing for {dueno}.\n", file=sys.stderr)
|
|
891
|
+
return None
|
|
892
|
+
|
|
893
|
+
# Most recently touched first: the ones you are working in are the ones you are
|
|
894
|
+
# deciding about, and they should not be forty rows down.
|
|
895
|
+
filas.sort(key=lambda f: f[2] or "", reverse=True)
|
|
896
|
+
marcados = set(ya_marcados) or mios(usuario, correo, [(n, r) for n, r, _, _ in filas if r])
|
|
897
|
+
|
|
898
|
+
ops = []
|
|
899
|
+
for nombre, ruta, cuando, _ in filas:
|
|
900
|
+
donde = ruta.replace(os.path.expanduser("~"), "~") if ruta else "· not cloned here"
|
|
901
|
+
ops.append((nombre, nombre, f"{cuando or '—'} {donde}"))
|
|
902
|
+
|
|
903
|
+
elegidos = ui.elige(
|
|
904
|
+
f"Which folders do you answer for, {usuario}?",
|
|
905
|
+
ops,
|
|
906
|
+
marcadas=marcados,
|
|
907
|
+
minimo=0,
|
|
908
|
+
step=3,
|
|
909
|
+
of=PASOS,
|
|
910
|
+
ayuda="One window each, with an agent in it. Ticked are the ones you have "
|
|
911
|
+
"commits in this year. Pick what you would answer a question about — "
|
|
912
|
+
"nothing is a valid answer for a role that owns no folders.",
|
|
913
|
+
)
|
|
914
|
+
if elegidos is None:
|
|
915
|
+
return None
|
|
916
|
+
|
|
917
|
+
rutas = {n: r for n, r, _, _ in filas if r}
|
|
918
|
+
if dueno:
|
|
919
|
+
faltan = [n for n in elegidos if n not in rutas]
|
|
920
|
+
rutas.update(
|
|
921
|
+
clona(dueno, faltan, os.environ.get("CITY_CODE_DIR", os.path.expanduser("~/codigo")))
|
|
922
|
+
)
|
|
923
|
+
return elegidos, rutas
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def dominio_configurado(a, nueva, datos):
|
|
927
|
+
"""Resolve question one, including the explicit CLI non-interactive path."""
|
|
928
|
+
dominio_actual = domains.de_ciudad(datos)
|
|
929
|
+
if a.domain:
|
|
930
|
+
dominio = domains.canonico(a.domain)
|
|
931
|
+
if not domains.obtiene(dominio):
|
|
932
|
+
conocidos = ", ".join(p["id"] for p in domains.catalogo())
|
|
933
|
+
print(f" Unknown domain {a.domain!r}. Available: {conocidos}.", file=sys.stderr)
|
|
934
|
+
return None
|
|
935
|
+
return dominio
|
|
936
|
+
return pregunta_dominio(dominio_actual) if nueva else dominio_actual
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def rol_configurado(a, nueva, dominio, actual=""):
|
|
940
|
+
"""Resolve question two; a domain switch always asks for a matching role."""
|
|
941
|
+
if a.role:
|
|
942
|
+
return a.role
|
|
943
|
+
solo_otro_ajuste = a.goal or a.repos or a.agent_roles or a.engines
|
|
944
|
+
if nueva or a.domain or not solo_otro_ajuste:
|
|
945
|
+
return pregunta_rol(dominio, actual)
|
|
946
|
+
return actual or domains.roles_de(dominio)[0][0]
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def pregunta_seat_yolo(datos):
|
|
950
|
+
"""Question six: whether the chair itself asks permission. Per city, in
|
|
951
|
+
city.yml — locally the seat is the owner's own hands on the owner's own
|
|
952
|
+
machine, and asking the owner for permission in their own chair is a
|
|
953
|
+
choice, not a law. Repo windows keep their own yolo/cage story either way.
|
|
954
|
+
False means the person quit the wizard."""
|
|
955
|
+
yolo_actual = cities.lee_clave(datos, "seat_yolo") == "1"
|
|
956
|
+
eleccion = ui.una(
|
|
957
|
+
"Does your own chair ask permission?",
|
|
958
|
+
[
|
|
959
|
+
(
|
|
960
|
+
"ask",
|
|
961
|
+
"Ask first" + ("" if yolo_actual else " (current)"),
|
|
962
|
+
"the seat confirms actions with you — the cautious default",
|
|
963
|
+
),
|
|
964
|
+
(
|
|
965
|
+
"yolo",
|
|
966
|
+
"No prompts (yolo)" + (" (current)" if yolo_actual else ""),
|
|
967
|
+
"your chair acts without asking; repo windows keep their own cage",
|
|
968
|
+
),
|
|
969
|
+
],
|
|
970
|
+
6,
|
|
971
|
+
PASOS,
|
|
972
|
+
ayuda="Changeable any time: ./bin/seat --seat-yolo on|off. "
|
|
973
|
+
"Launching with --no-yolo still brakes the whole session, seat included.",
|
|
974
|
+
)
|
|
975
|
+
if eleccion is None:
|
|
976
|
+
return False
|
|
977
|
+
cities.pon_clave(datos, "seat_yolo", "1" if eleccion == "yolo" else "0")
|
|
978
|
+
return True
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def hay_ajustes(a, nueva):
|
|
982
|
+
"""Whether this invocation re-asks any of the six questions."""
|
|
983
|
+
return bool(nueva or a.repos or a.agent_roles or a.goal or a.domain or a.role or a.engines)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def pregunta_runtime(a, nueva, elegidos, datos):
|
|
987
|
+
"""Questions five and six together: what runs in each window, and whether
|
|
988
|
+
the chair itself asks permission. None means the person quit; {} means
|
|
989
|
+
neither question was due this invocation."""
|
|
990
|
+
if not (nueva or a.engines):
|
|
991
|
+
return {}
|
|
992
|
+
motores = pregunta_motores(elegidos)
|
|
993
|
+
if motores is None or not pregunta_seat_yolo(datos):
|
|
994
|
+
return None
|
|
995
|
+
return motores
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def ajusta_seat_yolo(a, datos, nueva):
|
|
999
|
+
"""The --seat-yolo flag: write the per-city choice. Returns True when the
|
|
1000
|
+
invocation was ONLY this adjustment, so the caller can stop there."""
|
|
1001
|
+
if not a.seat_yolo:
|
|
1002
|
+
return False
|
|
1003
|
+
cities.pon_clave(datos, "seat_yolo", "1" if a.seat_yolo == "on" else "0")
|
|
1004
|
+
estado_seat = "yolo" if a.seat_yolo == "on" else "asks permission"
|
|
1005
|
+
print(f" Seat runtime for this city: {estado_seat} (city.yml seat_yolo).")
|
|
1006
|
+
solo = not hay_ajustes(a, nueva)
|
|
1007
|
+
if solo:
|
|
1008
|
+
print(" It applies the next time the session opens.\n")
|
|
1009
|
+
return solo
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def configura(a, ficha, nueva, datos, usuario):
|
|
1013
|
+
"""The six questions, or the setting being re-asked. False means quit."""
|
|
1014
|
+
previo = open(ficha).read() if not nueva else ""
|
|
1015
|
+
|
|
1016
|
+
def campo(k, defecto=""):
|
|
1017
|
+
return card.campo(previo, k, defecto)
|
|
1018
|
+
|
|
1019
|
+
# Domain owns the vocabulary and relevant roles; it is never inferred from
|
|
1020
|
+
# repo names. Old `kind: product` cities resolve to software.
|
|
1021
|
+
dominio = dominio_configurado(a, nueva, datos)
|
|
1022
|
+
if not dominio:
|
|
1023
|
+
return False
|
|
1024
|
+
rol = rol_configurado(a, nueva, dominio, campo("role"))
|
|
1025
|
+
if not rol:
|
|
1026
|
+
return False
|
|
1027
|
+
|
|
1028
|
+
# 3. The folders.
|
|
1029
|
+
if nueva or a.repos:
|
|
1030
|
+
resultado = elige_repos(usuario, [] if nueva else repos_de_ficha(ficha))
|
|
1031
|
+
if resultado is None:
|
|
1032
|
+
return False
|
|
1033
|
+
elegidos, _ = resultado
|
|
1034
|
+
else:
|
|
1035
|
+
elegidos = repos_de_ficha(ficha)
|
|
1036
|
+
|
|
1037
|
+
# Every concrete repo agent has a specialty distinct from the seat's chair
|
|
1038
|
+
# role. Legacy cards resolve missing assignments to the honest blank role.
|
|
1039
|
+
actuales_roles = card.lee(ficha).get("repo_roles", {}) if not nueva else {}
|
|
1040
|
+
cambia_roles_repos = nueva or a.repos or a.agent_roles
|
|
1041
|
+
if cambia_roles_repos:
|
|
1042
|
+
roles_repos = pregunta_roles_repos(elegidos, dominio, actuales_roles)
|
|
1043
|
+
if roles_repos is None:
|
|
1044
|
+
return False
|
|
1045
|
+
else:
|
|
1046
|
+
roles_repos = card.normaliza_roles_repos(elegidos, actuales_roles)
|
|
1047
|
+
|
|
1048
|
+
# 4. The goal, which is the one thing a round cannot work without.
|
|
1049
|
+
objetivo = None
|
|
1050
|
+
if nueva or a.goal:
|
|
1051
|
+
objetivo = pregunta_objetivo(usuario)
|
|
1052
|
+
elif campo("goals_defined") == "true":
|
|
1053
|
+
objetivo = card.objetivo(previo, usuario)
|
|
1054
|
+
|
|
1055
|
+
# 5 and 6. The engines, and whether the chair itself asks permission.
|
|
1056
|
+
motores = pregunta_runtime(a, nueva, elegidos, datos)
|
|
1057
|
+
if motores is None:
|
|
1058
|
+
return False
|
|
1059
|
+
|
|
1060
|
+
escribe_cambios(
|
|
1061
|
+
ficha,
|
|
1062
|
+
nueva,
|
|
1063
|
+
datos,
|
|
1064
|
+
usuario,
|
|
1065
|
+
dominio,
|
|
1066
|
+
rol,
|
|
1067
|
+
elegidos,
|
|
1068
|
+
roles_repos,
|
|
1069
|
+
objetivo,
|
|
1070
|
+
a.goal,
|
|
1071
|
+
motores,
|
|
1072
|
+
cambia_roles_repos,
|
|
1073
|
+
)
|
|
1074
|
+
resume(datos, usuario, dominio, rol, elegidos, roles_repos, objetivo)
|
|
1075
|
+
return True
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def escribe_cambios(
|
|
1079
|
+
ficha,
|
|
1080
|
+
nueva,
|
|
1081
|
+
datos,
|
|
1082
|
+
usuario,
|
|
1083
|
+
dominio,
|
|
1084
|
+
rol,
|
|
1085
|
+
elegidos,
|
|
1086
|
+
roles_repos,
|
|
1087
|
+
objetivo,
|
|
1088
|
+
con_goal,
|
|
1089
|
+
motores,
|
|
1090
|
+
actualiza_roles_repos,
|
|
1091
|
+
):
|
|
1092
|
+
"""Land the answers on the card — the whole template for a new one, surgery
|
|
1093
|
+
for one that exists, because an existing card may carry round history and
|
|
1094
|
+
changing your role must not erase what happened."""
|
|
1095
|
+
if nueva:
|
|
1096
|
+
escribe_ficha(
|
|
1097
|
+
ficha,
|
|
1098
|
+
usuario,
|
|
1099
|
+
rol,
|
|
1100
|
+
elegidos,
|
|
1101
|
+
objetivo,
|
|
1102
|
+
cities.slug_ciudad(datos),
|
|
1103
|
+
roles_repos,
|
|
1104
|
+
)
|
|
1105
|
+
else:
|
|
1106
|
+
card.cambia_rol(
|
|
1107
|
+
ficha,
|
|
1108
|
+
rol,
|
|
1109
|
+
roles.agente(usuario, rol, cities.slug_ciudad(datos)),
|
|
1110
|
+
roles.oficio(rol, datos),
|
|
1111
|
+
)
|
|
1112
|
+
card.cambia_repos(ficha, elegidos)
|
|
1113
|
+
if actualiza_roles_repos:
|
|
1114
|
+
card.cambia_roles_repos(ficha, elegidos, roles_repos)
|
|
1115
|
+
if con_goal or objetivo is not None:
|
|
1116
|
+
card.cambia_objetivo(ficha, objetivo)
|
|
1117
|
+
# After the template or the surgery, either way: pon_campo is itself surgical.
|
|
1118
|
+
for clave, valor in motores.items():
|
|
1119
|
+
card.pon_campo(ficha, clave, valor)
|
|
1120
|
+
domains.selecciona(datos, dominio)
|
|
1121
|
+
conocimiento = domains.materializa(datos, dominio, rol)
|
|
1122
|
+
for rol_repo in sorted(set(roles_repos.values()) - {"blank", rol}):
|
|
1123
|
+
conocimiento += domains.materializa(datos, dominio, rol_repo)
|
|
1124
|
+
if conocimiento:
|
|
1125
|
+
print(" Domain knowledge installed as editable files: " + ", ".join(conocimiento) + ".")
|
|
1126
|
+
otros = sorted(k.split(".", 1)[1] for k, v in motores.items() if v and k.startswith("runs."))
|
|
1127
|
+
if otros:
|
|
1128
|
+
print(f" Windows on another engine: {', '.join(otros)} — the rest run Claude.")
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def resume(datos, usuario, dominio, rol, elegidos, roles_repos, objetivo):
|
|
1132
|
+
"""Say what was written, and what to do with it."""
|
|
1133
|
+
print(f" City address: {cities.direccion(usuario, datos)}.")
|
|
1134
|
+
pack = domains.obtiene(dominio)
|
|
1135
|
+
print(f" Domain: {pack['name'] if pack else dominio}.")
|
|
1136
|
+
print(f" Your role there: {roles.nombre(rol)}.")
|
|
1137
|
+
if elegidos:
|
|
1138
|
+
print(
|
|
1139
|
+
f" {len(elegidos)} folders on your card — one window each. "
|
|
1140
|
+
f"Change them with ./bin/seat --repos"
|
|
1141
|
+
)
|
|
1142
|
+
resumen_roles = ", ".join(
|
|
1143
|
+
f"{repo}: {roles.nombre(roles_repos.get(repo, 'blank'))}" for repo in sorted(elegidos)
|
|
1144
|
+
)
|
|
1145
|
+
print(f" Support-agent roles: {resumen_roles}.")
|
|
1146
|
+
else:
|
|
1147
|
+
print(
|
|
1148
|
+
" No support repos in this city. Its seat still opens on its own;\n"
|
|
1149
|
+
" roads can connect it to other local or remote cities."
|
|
1150
|
+
)
|
|
1151
|
+
print(f" Goal: {objetivo['title'] if objetivo else 'none yet — ./bin/seat --goal'}")
|
|
1152
|
+
# Only in our own folder: a real data repo's modelling is not ours to touch.
|
|
1153
|
+
if cities.gestionada(datos, usuario):
|
|
1154
|
+
for h in escribe_suelo(datos, elegidos, dominio):
|
|
1155
|
+
print(f" · {h}")
|
|
1156
|
+
if elegidos:
|
|
1157
|
+
print(
|
|
1158
|
+
f"\n Your city is drawable now — no wizard, no account:\n"
|
|
1159
|
+
f" ./bin/city {datos.replace(os.path.expanduser('~'), '~')}"
|
|
1160
|
+
)
|
|
1161
|
+
print()
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def ciudad_elegida(cual, usuario):
|
|
1165
|
+
"""--city resolved to a folder, the usual resolution otherwise, '' plus the
|
|
1166
|
+
list of known cities on a miss — a wrong name should teach, not strand."""
|
|
1167
|
+
if not cual:
|
|
1168
|
+
return donde_viven_las_fichas(usuario)
|
|
1169
|
+
datos = cities.resuelve(cual, usuario)
|
|
1170
|
+
if not datos:
|
|
1171
|
+
conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none yet"
|
|
1172
|
+
print(
|
|
1173
|
+
f" No city called {cual!r}. Known here: {conocidas}.\n"
|
|
1174
|
+
f" A path works too: ./bin/seat --city ~/clientes/acme\n",
|
|
1175
|
+
file=sys.stderr,
|
|
1176
|
+
)
|
|
1177
|
+
return datos
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def entrega_a_la_sesion(a, datos, usuario):
|
|
1181
|
+
"""Hand over to the session builder, which is the part that already worked.
|
|
1182
|
+
|
|
1183
|
+
This replaces the process rather than waiting on it: from here on the session
|
|
1184
|
+
script owns the terminal, and `seat` has no more decisions to take. The two
|
|
1185
|
+
engine flags travel as environment because they belong to THIS launch and must
|
|
1186
|
+
not be written to anybody's card.
|
|
1187
|
+
"""
|
|
1188
|
+
orden = [os.path.join(GUIONES, "city-session.sh"), usuario, "--claude"]
|
|
1189
|
+
if a.no_yolo:
|
|
1190
|
+
orden.append("--no-yolo")
|
|
1191
|
+
if a.no_sync:
|
|
1192
|
+
orden.append("--no-sync")
|
|
1193
|
+
if a.only:
|
|
1194
|
+
orden += ["--only", a.only]
|
|
1195
|
+
entorno = dict(
|
|
1196
|
+
os.environ,
|
|
1197
|
+
AGENTS_CITY_DATA=datos,
|
|
1198
|
+
AGENTS_CITY_HOME=cities.raiz(),
|
|
1199
|
+
AGENTS_CITY_USER=usuario,
|
|
1200
|
+
CITY_ADDRESS=cities.direccion(usuario, datos),
|
|
1201
|
+
)
|
|
1202
|
+
if a.model:
|
|
1203
|
+
entorno["CITY_MODEL"] = a.model
|
|
1204
|
+
if a.effort:
|
|
1205
|
+
entorno["CITY_EFFORT"] = a.effort
|
|
1206
|
+
os.execve(orden[0], orden, entorno)
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def argumentos():
|
|
1210
|
+
"""Every flag the seat takes. Its own function so `main` stays readable: the
|
|
1211
|
+
door's job is the order of the steps, not the shape of the arguments."""
|
|
1212
|
+
ap = argparse.ArgumentParser(add_help=False)
|
|
1213
|
+
ap.add_argument("usuario", nargs="?", default="")
|
|
1214
|
+
ap.add_argument("--repos", action="store_true", help="pick your repos again")
|
|
1215
|
+
ap.add_argument(
|
|
1216
|
+
"--agent-roles",
|
|
1217
|
+
"--agents",
|
|
1218
|
+
action="store_true",
|
|
1219
|
+
dest="agent_roles",
|
|
1220
|
+
help="choose the operating role each repo agent adopts",
|
|
1221
|
+
)
|
|
1222
|
+
ap.add_argument("--goal", action="store_true", help="set your goal again")
|
|
1223
|
+
ap.add_argument(
|
|
1224
|
+
"--engines", action="store_true", help="what runs in each window: a model, or another CLI"
|
|
1225
|
+
)
|
|
1226
|
+
ap.add_argument(
|
|
1227
|
+
"--city",
|
|
1228
|
+
default="",
|
|
1229
|
+
metavar="NAME|PATH",
|
|
1230
|
+
help="which city to sit in (default: the usual resolution)",
|
|
1231
|
+
)
|
|
1232
|
+
ap.add_argument(
|
|
1233
|
+
"--domain",
|
|
1234
|
+
default="",
|
|
1235
|
+
metavar="DOMAIN",
|
|
1236
|
+
help="set the work domain (software, healthcare, legal...)",
|
|
1237
|
+
)
|
|
1238
|
+
ap.add_argument("--role", default="", help="set your role without being asked")
|
|
1239
|
+
ap.add_argument("--no-yolo", action="store_true")
|
|
1240
|
+
ap.add_argument(
|
|
1241
|
+
"--seat-yolo",
|
|
1242
|
+
default="",
|
|
1243
|
+
choices=["on", "off"],
|
|
1244
|
+
metavar="on|off",
|
|
1245
|
+
help="whether the chair itself runs without permission prompts (stored per city)",
|
|
1246
|
+
)
|
|
1247
|
+
ap.add_argument("--no-sync", action="store_true")
|
|
1248
|
+
ap.add_argument("--only", default="")
|
|
1249
|
+
ap.add_argument(
|
|
1250
|
+
"--model",
|
|
1251
|
+
default="",
|
|
1252
|
+
metavar="ALIAS",
|
|
1253
|
+
help="this launch's model for every window (opus, sonnet, haiku…)",
|
|
1254
|
+
)
|
|
1255
|
+
ap.add_argument(
|
|
1256
|
+
"--effort",
|
|
1257
|
+
default="",
|
|
1258
|
+
metavar="LEVEL",
|
|
1259
|
+
help="this launch's effort (low, medium, high, xhigh, max)",
|
|
1260
|
+
)
|
|
1261
|
+
ap.add_argument("-h", "--help", action="store_true")
|
|
1262
|
+
return ap.parse_args()
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def puerta_del_dueno(a, usuario):
|
|
1266
|
+
"""The two ownership refusals at the door: a second person cannot take this
|
|
1267
|
+
seat, and another owner's city cannot be sat in. Returns the city folder,
|
|
1268
|
+
or None with the reason already printed."""
|
|
1269
|
+
if a.usuario and gh.usuario_de_correo(a.usuario) != usuario:
|
|
1270
|
+
print(
|
|
1271
|
+
" A personal city has exactly one owner seat. To open another of "
|
|
1272
|
+
"your cities, use `--city <name>`.",
|
|
1273
|
+
file=sys.stderr,
|
|
1274
|
+
)
|
|
1275
|
+
return None
|
|
1276
|
+
# v1 put the whole first city directly in ~/.agents-city. Move it once, with
|
|
1277
|
+
# a recovery copy, before resolving the selected v2 city.
|
|
1278
|
+
if not os.environ.get("AGENTS_CITY_DATA"):
|
|
1279
|
+
cities.migra_legacy(usuario, anunciar=True)
|
|
1280
|
+
datos = ciudad_elegida(a.city, usuario)
|
|
1281
|
+
if not datos:
|
|
1282
|
+
return None
|
|
1283
|
+
owner = cities.lee_clave(datos, "owner")
|
|
1284
|
+
if owner and owner != usuario:
|
|
1285
|
+
print(
|
|
1286
|
+
f" This city belongs to {owner!r}, not {usuario!r}. Import one of "
|
|
1287
|
+
"your own cities or connect it by road instead.",
|
|
1288
|
+
file=sys.stderr,
|
|
1289
|
+
)
|
|
1290
|
+
return None
|
|
1291
|
+
return datos
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def main():
|
|
1295
|
+
a = argumentos()
|
|
1296
|
+
if a.help:
|
|
1297
|
+
print(__doc__)
|
|
1298
|
+
return 0
|
|
1299
|
+
|
|
1300
|
+
usuario = quien_soy()
|
|
1301
|
+
datos = puerta_del_dueno(a, usuario)
|
|
1302
|
+
if not datos:
|
|
1303
|
+
return 1
|
|
1304
|
+
# Remember it, so every door — the hall's switcher included — can offer it.
|
|
1305
|
+
cities.asegura_metadata(datos, usuario)
|
|
1306
|
+
cities.registra(datos, usuario)
|
|
1307
|
+
cities.selecciona(usuario, datos)
|
|
1308
|
+
ficha = os.path.join(datos, f"{usuario}.md")
|
|
1309
|
+
nueva = not os.path.exists(ficha)
|
|
1310
|
+
|
|
1311
|
+
if ajusta_seat_yolo(a, datos, nueva):
|
|
1312
|
+
return 0
|
|
1313
|
+
|
|
1314
|
+
if hay_ajustes(a, nueva):
|
|
1315
|
+
if nueva:
|
|
1316
|
+
print(
|
|
1317
|
+
f"\n First time here. Six questions and you are in — the work "
|
|
1318
|
+
f"domain, your chair role,\n your folders and their agent roles, "
|
|
1319
|
+
f"one goal, what runs in each window, "
|
|
1320
|
+
f"and whether\n your own chair asks permission. "
|
|
1321
|
+
f"Your card lands at\n "
|
|
1322
|
+
f"{ficha.replace(os.path.expanduser('~'), '~')}\n"
|
|
1323
|
+
)
|
|
1324
|
+
if not configura(a, ficha, nueva, datos, usuario):
|
|
1325
|
+
print(" Nothing changed.\n")
|
|
1326
|
+
return 1
|
|
1327
|
+
|
|
1328
|
+
# Cards written before personal cities addressed the role (`user/lead`). A
|
|
1329
|
+
# city address is stable when the role changes, and two cities never collide.
|
|
1330
|
+
esperado = cities.direccion(usuario, datos)
|
|
1331
|
+
leida = card.lee(ficha)
|
|
1332
|
+
if leida.get("agent") != esperado:
|
|
1333
|
+
card.pon_campo(ficha, "agent", esperado)
|
|
1334
|
+
|
|
1335
|
+
# Everything the session needs, installed rather than asked for. One command
|
|
1336
|
+
# means one command: "now go and install tmux" is a second one.
|
|
1337
|
+
if not asegura_tmux():
|
|
1338
|
+
return 1
|
|
1339
|
+
agente = card.lee(ficha).get("agent") or cities.direccion(usuario, datos)
|
|
1340
|
+
# No Claude in the seat window, no reason to install a Claude plugin — and no
|
|
1341
|
+
# reason to make somebody restart Claude Code for something they will not use.
|
|
1342
|
+
if not card.campo(open(ficha, encoding="utf-8").read(), "runs.seat"):
|
|
1343
|
+
asegura_plugin(usuario, agente, datos)
|
|
1344
|
+
|
|
1345
|
+
entrega_a_la_sesion(a, datos, usuario)
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
if __name__ == "__main__":
|
|
1349
|
+
sys.exit(main())
|