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.
Files changed (449) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.es.md +2226 -0
  5. package/README.md +2196 -0
  6. package/benchmarks/committee/README.md +39 -0
  7. package/benchmarks/committee/metrics.py +51 -0
  8. package/benchmarks/committee/run.py +64 -0
  9. package/benchmarks/committee/traces/01-single.json +8 -0
  10. package/benchmarks/committee/traces/02-mesh.json +16 -0
  11. package/benchmarks/committee/traces/03-chair.json +15 -0
  12. package/benchmarks/latency/README.md +35 -0
  13. package/benchmarks/latency/fake-claude-cli.mjs +119 -0
  14. package/benchmarks/latency/fake-native-server.mjs +340 -0
  15. package/benchmarks/latency/fake-tui.py +91 -0
  16. package/benchmarks/latency/live.py +380 -0
  17. package/benchmarks/stress/README.md +20 -0
  18. package/benchmarks/stress/run.py +487 -0
  19. package/bin/agents +4 -0
  20. package/bin/agents-city.js +119 -0
  21. package/bin/benchmark +17 -0
  22. package/bin/bus +6 -0
  23. package/bin/cities +3 -0
  24. package/bin/city +86 -0
  25. package/bin/committee +6 -0
  26. package/bin/demo +200 -0
  27. package/bin/doctor +3 -0
  28. package/bin/exit +5 -0
  29. package/bin/hall +73 -0
  30. package/bin/hall.html +498 -0
  31. package/bin/logs +3 -0
  32. package/bin/navegador.mjs +243 -0
  33. package/bin/report.py +14 -0
  34. package/bin/reset +3 -0
  35. package/bin/road +3 -0
  36. package/bin/seat +10 -0
  37. package/bin/serve.py +1579 -0
  38. package/bin/setup.py +753 -0
  39. package/bin/shortcut +3 -0
  40. package/bin/skills +3 -0
  41. package/bin/test +133 -0
  42. package/bin/test-actualiza.py +130 -0
  43. package/bin/test-adapter.py +183 -0
  44. package/bin/test-admision.py +99 -0
  45. package/bin/test-atajos.py +301 -0
  46. package/bin/test-avatar.py +58 -0
  47. package/bin/test-benchmark.py +40 -0
  48. package/bin/test-broker.py +334 -0
  49. package/bin/test-cage.py +374 -0
  50. package/bin/test-card.py +402 -0
  51. package/bin/test-channel.py +379 -0
  52. package/bin/test-cities.py +453 -0
  53. package/bin/test-claude-runtime.py +252 -0
  54. package/bin/test-committee.py +340 -0
  55. package/bin/test-contracts.py +551 -0
  56. package/bin/test-crecimiento.py +164 -0
  57. package/bin/test-demo.py +308 -0
  58. package/bin/test-doctor.py +139 -0
  59. package/bin/test-domains.py +151 -0
  60. package/bin/test-evidencia.py +50 -0
  61. package/bin/test-exit.py +128 -0
  62. package/bin/test-hall-protocol.py +93 -0
  63. package/bin/test-launch.py +169 -0
  64. package/bin/test-live-feed.py +508 -0
  65. package/bin/test-navegador.py +140 -0
  66. package/bin/test-pairing.py +99 -0
  67. package/bin/test-parcels.py +283 -0
  68. package/bin/test-runtime-failures.py +252 -0
  69. package/bin/test-runtime-ui.py +488 -0
  70. package/bin/test-runtime.py +268 -0
  71. package/bin/test-rutas.py +80 -0
  72. package/bin/test-seat.py +1598 -0
  73. package/bin/test-security.py +150 -0
  74. package/bin/test-serve.py +1251 -0
  75. package/bin/test-stress.py +64 -0
  76. package/bin/test-widgets.py +143 -0
  77. package/bin/test-workspace.py +251 -0
  78. package/bin/testlib.py +118 -0
  79. package/bin/tokens.py +14 -0
  80. package/bin/update +3 -0
  81. package/bus/scripts/deploy.sh +60 -0
  82. package/bus/scripts/mint-token.sh +68 -0
  83. package/bus/scripts/setup-dev.sh +79 -0
  84. package/bus/scripts/test-channel.ts +8 -0
  85. package/bus/scripts/test-hub.ts +266 -0
  86. package/bus/scripts/test-local.sh +40 -0
  87. package/bus/scripts/test-queue.ts +132 -0
  88. package/bus/worker/package-lock.json +1971 -0
  89. package/bus/worker/package.json +17 -0
  90. package/bus/worker/src/index.ts +485 -0
  91. package/bus/worker/tsconfig.json +13 -0
  92. package/bus/worker/wrangler.toml +32 -0
  93. package/city/oven/README.md +59 -0
  94. package/city/oven/collect.py +43 -0
  95. package/city/oven/oven.html +4 -0
  96. package/city/scripts/history.py +120 -0
  97. package/city/scripts/seed.py +442 -0
  98. package/city/web/assets/modelos/License.txt +28 -0
  99. package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
  100. package/city/web/assets/modelos/building-a.glb +0 -0
  101. package/city/web/assets/modelos/building-b.glb +0 -0
  102. package/city/web/assets/modelos/building-c.glb +0 -0
  103. package/city/web/assets/modelos/building-d.glb +0 -0
  104. package/city/web/assets/modelos/building-e.glb +0 -0
  105. package/city/web/assets/modelos/building-f.glb +0 -0
  106. package/city/web/assets/modelos/building-g.glb +0 -0
  107. package/city/web/assets/modelos/building-h.glb +0 -0
  108. package/city/web/assets/sprites/building-a.png +0 -0
  109. package/city/web/assets/sprites/building-b.png +0 -0
  110. package/city/web/assets/sprites/building-c.png +0 -0
  111. package/city/web/assets/sprites/building-d.png +0 -0
  112. package/city/web/assets/sprites/building-e.png +0 -0
  113. package/city/web/assets/sprites/building-f.png +0 -0
  114. package/city/web/assets/sprites/building-g.png +0 -0
  115. package/city/web/assets/sprites/building-h.png +0 -0
  116. package/city/web/assets/sprites/building-i.png +0 -0
  117. package/city/web/assets/sprites/building-j.png +0 -0
  118. package/city/web/assets/sprites/building-k.png +0 -0
  119. package/city/web/assets/sprites/building-l.png +0 -0
  120. package/city/web/assets/sprites/building-m.png +0 -0
  121. package/city/web/assets/sprites/building-n.png +0 -0
  122. package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
  123. package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
  124. package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
  125. package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
  126. package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
  127. package/city/web/assets/sprites/catalogo.json +47 -0
  128. package/city/web/assets/sprites/detail-awning-wide.png +0 -0
  129. package/city/web/assets/sprites/detail-awning.png +0 -0
  130. package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
  131. package/city/web/assets/sprites/detail-overhang.png +0 -0
  132. package/city/web/assets/sprites/detail-parasol-a.png +0 -0
  133. package/city/web/assets/sprites/detail-parasol-b.png +0 -0
  134. package/city/web/assets/sprites/low-detail-building-a.png +0 -0
  135. package/city/web/assets/sprites/low-detail-building-b.png +0 -0
  136. package/city/web/assets/sprites/low-detail-building-c.png +0 -0
  137. package/city/web/assets/sprites/low-detail-building-d.png +0 -0
  138. package/city/web/assets/sprites/low-detail-building-e.png +0 -0
  139. package/city/web/assets/sprites/low-detail-building-f.png +0 -0
  140. package/city/web/assets/sprites/low-detail-building-g.png +0 -0
  141. package/city/web/assets/sprites/low-detail-building-h.png +0 -0
  142. package/city/web/assets/sprites/low-detail-building-i.png +0 -0
  143. package/city/web/assets/sprites/low-detail-building-j.png +0 -0
  144. package/city/web/assets/sprites/low-detail-building-k.png +0 -0
  145. package/city/web/assets/sprites/low-detail-building-l.png +0 -0
  146. package/city/web/assets/sprites/low-detail-building-m.png +0 -0
  147. package/city/web/assets/sprites/low-detail-building-n.png +0 -0
  148. package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
  149. package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
  150. package/city/web/assets/sprites/medidas.json +248 -0
  151. package/city/web/dist/city.js +939 -0
  152. package/city/web/dist/index.html +294 -0
  153. package/city/web/dist/modelos/License.txt +28 -0
  154. package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
  155. package/city/web/dist/modelos/building-a.glb +0 -0
  156. package/city/web/dist/modelos/building-b.glb +0 -0
  157. package/city/web/dist/modelos/building-c.glb +0 -0
  158. package/city/web/dist/modelos/building-d.glb +0 -0
  159. package/city/web/dist/modelos/building-e.glb +0 -0
  160. package/city/web/dist/modelos/building-f.glb +0 -0
  161. package/city/web/dist/modelos/building-g.glb +0 -0
  162. package/city/web/dist/modelos/building-h.glb +0 -0
  163. package/city/web/dist/sprites/building-a.png +0 -0
  164. package/city/web/dist/sprites/building-b.png +0 -0
  165. package/city/web/dist/sprites/building-c.png +0 -0
  166. package/city/web/dist/sprites/building-d.png +0 -0
  167. package/city/web/dist/sprites/building-e.png +0 -0
  168. package/city/web/dist/sprites/building-f.png +0 -0
  169. package/city/web/dist/sprites/building-g.png +0 -0
  170. package/city/web/dist/sprites/building-h.png +0 -0
  171. package/city/web/dist/sprites/building-i.png +0 -0
  172. package/city/web/dist/sprites/building-j.png +0 -0
  173. package/city/web/dist/sprites/building-k.png +0 -0
  174. package/city/web/dist/sprites/building-l.png +0 -0
  175. package/city/web/dist/sprites/building-m.png +0 -0
  176. package/city/web/dist/sprites/building-n.png +0 -0
  177. package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
  178. package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
  179. package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
  180. package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
  181. package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
  182. package/city/web/dist/sprites/catalogo.json +47 -0
  183. package/city/web/dist/sprites/detail-awning-wide.png +0 -0
  184. package/city/web/dist/sprites/detail-awning.png +0 -0
  185. package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
  186. package/city/web/dist/sprites/detail-overhang.png +0 -0
  187. package/city/web/dist/sprites/detail-parasol-a.png +0 -0
  188. package/city/web/dist/sprites/detail-parasol-b.png +0 -0
  189. package/city/web/dist/sprites/low-detail-building-a.png +0 -0
  190. package/city/web/dist/sprites/low-detail-building-b.png +0 -0
  191. package/city/web/dist/sprites/low-detail-building-c.png +0 -0
  192. package/city/web/dist/sprites/low-detail-building-d.png +0 -0
  193. package/city/web/dist/sprites/low-detail-building-e.png +0 -0
  194. package/city/web/dist/sprites/low-detail-building-f.png +0 -0
  195. package/city/web/dist/sprites/low-detail-building-g.png +0 -0
  196. package/city/web/dist/sprites/low-detail-building-h.png +0 -0
  197. package/city/web/dist/sprites/low-detail-building-i.png +0 -0
  198. package/city/web/dist/sprites/low-detail-building-j.png +0 -0
  199. package/city/web/dist/sprites/low-detail-building-k.png +0 -0
  200. package/city/web/dist/sprites/low-detail-building-l.png +0 -0
  201. package/city/web/dist/sprites/low-detail-building-m.png +0 -0
  202. package/city/web/dist/sprites/low-detail-building-n.png +0 -0
  203. package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
  204. package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
  205. package/city/web/dist/sprites/medidas.json +248 -0
  206. package/city/web/dist-hall/hall.js +1363 -0
  207. package/city/web/index.html +294 -0
  208. package/city/web/package.json +18 -0
  209. package/city/web/sella.py +21 -0
  210. package/city/web/src/activity-actors.ts +47 -0
  211. package/city/web/src/activity.ts +172 -0
  212. package/city/web/src/ayuntamiento.ts +580 -0
  213. package/city/web/src/draw.ts +640 -0
  214. package/city/web/src/game-speech.ts +79 -0
  215. package/city/web/src/hall.ts +1959 -0
  216. package/city/web/src/main.ts +2252 -0
  217. package/city/web/src/oven.ts +124 -0
  218. package/city/web/src/people.ts +244 -0
  219. package/city/web/src/presencia.ts +193 -0
  220. package/city/web/src/puertas.ts +180 -0
  221. package/city/web/src/repo-roles.ts +59 -0
  222. package/city/worker/package.json +16 -0
  223. package/city/worker/schema.sql +117 -0
  224. package/city/worker/src/index.ts +702 -0
  225. package/city/worker/src/square.ts +218 -0
  226. package/city/worker/wrangler.toml +58 -0
  227. package/demo/ada.md +50 -0
  228. package/demo/bruno.md +62 -0
  229. package/demo/camila.md +56 -0
  230. package/demo/city/ada.md +40 -0
  231. package/demo/city/city.yml +8 -0
  232. package/demo/city/parcels.yml +15 -0
  233. package/demo/city/roads.json +4 -0
  234. package/demo/city/units.yml +10 -0
  235. package/demo/clinica/city.yml +8 -0
  236. package/demo/clinica/parcels.yml +15 -0
  237. package/demo/clinica/roads.json +4 -0
  238. package/demo/clinica/units.yml +9 -0
  239. package/demo/clinica/vera.md +46 -0
  240. package/demo/dante.md +54 -0
  241. package/demo/despacho/city.yml +8 -0
  242. package/demo/despacho/marta.md +46 -0
  243. package/demo/despacho/parcels.yml +15 -0
  244. package/demo/despacho/roads.json +4 -0
  245. package/demo/despacho/units.yml +9 -0
  246. package/demo/elsa.md +50 -0
  247. package/demo/farid.md +52 -0
  248. package/demo/greta.md +50 -0
  249. package/demo/hugo.md +49 -0
  250. package/demo/iris.md +49 -0
  251. package/demo/jonas.md +51 -0
  252. package/demo/kira.md +51 -0
  253. package/demo/luca.md +49 -0
  254. package/demo/parcels.yml +92 -0
  255. package/demo/seed.py +20 -0
  256. package/demo/show.py +161 -0
  257. package/demo/stories.py +824 -0
  258. package/demo/units.yml +33 -0
  259. package/docs/agents-first.md +106 -0
  260. package/docs/glossary.md +55 -0
  261. package/docs/map-live-layers.md +73 -0
  262. package/docs/security.md +265 -0
  263. package/docs/self-host.md +151 -0
  264. package/docs/testing.md +205 -0
  265. package/package.json +73 -0
  266. package/plugin/.claude-plugin/plugin.json +62 -0
  267. package/plugin/.mcp.json +15 -0
  268. package/plugin/channel/activity-cli.ts +16 -0
  269. package/plugin/channel/adapter-prompts.ts +113 -0
  270. package/plugin/channel/adapter.js +4292 -0
  271. package/plugin/channel/adapter.ts +133 -0
  272. package/plugin/channel/bus.js +20188 -0
  273. package/plugin/channel/bus.ts +131 -0
  274. package/plugin/channel/city-config.ts +125 -0
  275. package/plugin/channel/claude-channel.ts +58 -0
  276. package/plugin/channel/cli-args.ts +75 -0
  277. package/plugin/channel/client.js +4200 -0
  278. package/plugin/channel/client.ts +38 -0
  279. package/plugin/channel/committee/activity.ts +256 -0
  280. package/plugin/channel/committee/collection.ts +154 -0
  281. package/plugin/channel/committee/decision.ts +175 -0
  282. package/plugin/channel/committee/floor.ts +135 -0
  283. package/plugin/channel/committee/guards.ts +30 -0
  284. package/plugin/channel/committee/history.ts +38 -0
  285. package/plugin/channel/committee/render.ts +102 -0
  286. package/plugin/channel/committee/service.ts +72 -0
  287. package/plugin/channel/committee/storage.ts +92 -0
  288. package/plugin/channel/committee/types.ts +163 -0
  289. package/plugin/channel/committee/view.ts +63 -0
  290. package/plugin/channel/committee-cli.ts +109 -0
  291. package/plugin/channel/delivery-metrics.ts +61 -0
  292. package/plugin/channel/delivery-queue.ts +118 -0
  293. package/plugin/channel/hub/activity-controller.ts +72 -0
  294. package/plugin/channel/hub/activity-feed.ts +169 -0
  295. package/plugin/channel/hub/committee-controller.ts +60 -0
  296. package/plugin/channel/hub/connections.ts +48 -0
  297. package/plugin/channel/hub/diagnostics.ts +67 -0
  298. package/plugin/channel/hub/envelope-validity.ts +109 -0
  299. package/plugin/channel/hub/envelopes.ts +66 -0
  300. package/plugin/channel/hub/lifecycle.ts +91 -0
  301. package/plugin/channel/hub/local-roads.ts +89 -0
  302. package/plugin/channel/hub/remote-roads.ts +165 -0
  303. package/plugin/channel/hub/road-controller.ts +99 -0
  304. package/plugin/channel/hub-client.ts +149 -0
  305. package/plugin/channel/local-hub.js +5995 -0
  306. package/plugin/channel/local-hub.ts +349 -0
  307. package/plugin/channel/map-reporter.ts +48 -0
  308. package/plugin/channel/package-lock.json +1731 -0
  309. package/plugin/channel/package.json +19 -0
  310. package/plugin/channel/protocol.ts +98 -0
  311. package/plugin/channel/road-cli.ts +18 -0
  312. package/plugin/channel/run.sh +15 -0
  313. package/plugin/channel/runtime/claude.ts +458 -0
  314. package/plugin/channel/runtime/codex-config.ts +77 -0
  315. package/plugin/channel/runtime/codex.ts +669 -0
  316. package/plugin/channel/runtime/command.ts +62 -0
  317. package/plugin/channel/runtime/factory.ts +15 -0
  318. package/plugin/channel/runtime/json-rpc.ts +154 -0
  319. package/plugin/channel/runtime/kimi.ts +277 -0
  320. package/plugin/channel/runtime/opencode.ts +248 -0
  321. package/plugin/channel/runtime/process.ts +84 -0
  322. package/plugin/channel/runtime/types.ts +45 -0
  323. package/plugin/channel/runtime-files.ts +73 -0
  324. package/plugin/channel/runtime-gateway.js +6396 -0
  325. package/plugin/channel/runtime-gateway.ts +358 -0
  326. package/plugin/channel/runtime-metrics.ts +52 -0
  327. package/plugin/channel/runtime-subscription.ts +180 -0
  328. package/plugin/channel/terminal-delivery.ts +131 -0
  329. package/plugin/channel/untrusted.ts +60 -0
  330. package/plugin/commands/committee.md +31 -0
  331. package/plugin/commands/exit.md +32 -0
  332. package/plugin/commands/goals.md +20 -0
  333. package/plugin/commands/join.md +18 -0
  334. package/plugin/commands/notice.md +20 -0
  335. package/plugin/commands/propose.md +19 -0
  336. package/plugin/commands/round.md +17 -0
  337. package/plugin/commands/session.md +22 -0
  338. package/plugin/commands/settings.md +25 -0
  339. package/plugin/commands/setup.md +21 -0
  340. package/plugin/commands/team.md +23 -0
  341. package/plugin/domains/custom.md +27 -0
  342. package/plugin/domains/finance.md +31 -0
  343. package/plugin/domains/healthcare.md +34 -0
  344. package/plugin/domains/legal.md +31 -0
  345. package/plugin/domains/marketing.md +33 -0
  346. package/plugin/domains/operations.md +31 -0
  347. package/plugin/domains/research.md +32 -0
  348. package/plugin/domains/sales.md +30 -0
  349. package/plugin/domains/software.md +37 -0
  350. package/plugin/hooks/activity.sh +6 -0
  351. package/plugin/hooks/digging.sh +68 -0
  352. package/plugin/hooks/growth.sh +42 -0
  353. package/plugin/hooks/hooks.json +106 -0
  354. package/plugin/hooks/notice-on-pr.sh +35 -0
  355. package/plugin/hooks/notice-on-stop.sh +80 -0
  356. package/plugin/hooks/notice-pending.sh +58 -0
  357. package/plugin/hooks/solo-en-ciudad.sh +23 -0
  358. package/plugin/hooks/tokens.sh +47 -0
  359. package/plugin/roles/examples/account-executive.md +25 -0
  360. package/plugin/roles/examples/ai-manager.md +34 -0
  361. package/plugin/roles/examples/associate.md +24 -0
  362. package/plugin/roles/examples/brand-lead.md +20 -0
  363. package/plugin/roles/examples/cfo.md +20 -0
  364. package/plugin/roles/examples/city-lead.md +25 -0
  365. package/plugin/roles/examples/clinical-director.md +26 -0
  366. package/plugin/roles/examples/clinical-ops.md +25 -0
  367. package/plugin/roles/examples/clinician.md +25 -0
  368. package/plugin/roles/examples/compliance.md +27 -0
  369. package/plugin/roles/examples/content.md +24 -0
  370. package/plugin/roles/examples/controller.md +27 -0
  371. package/plugin/roles/examples/cpto.md +41 -0
  372. package/plugin/roles/examples/customer-success.md +25 -0
  373. package/plugin/roles/examples/data-engineer.md +37 -0
  374. package/plugin/roles/examples/data.md +60 -0
  375. package/plugin/roles/examples/dev.md +37 -0
  376. package/plugin/roles/examples/devops.md +39 -0
  377. package/plugin/roles/examples/enablement.md +24 -0
  378. package/plugin/roles/examples/ethics.md +24 -0
  379. package/plugin/roles/examples/fin-analytics.md +27 -0
  380. package/plugin/roles/examples/health-compliance.md +25 -0
  381. package/plugin/roles/examples/health-data.md +26 -0
  382. package/plugin/roles/examples/knowledge.md +25 -0
  383. package/plugin/roles/examples/lifecycle.md +26 -0
  384. package/plugin/roles/examples/llm-engineer.md +34 -0
  385. package/plugin/roles/examples/managing-partner.md +20 -0
  386. package/plugin/roles/examples/methods.md +23 -0
  387. package/plugin/roles/examples/operations-lead.md +23 -0
  388. package/plugin/roles/examples/ops.md +24 -0
  389. package/plugin/roles/examples/patient-safety.md +26 -0
  390. package/plugin/roles/examples/performance.md +27 -0
  391. package/plugin/roles/examples/po.md +36 -0
  392. package/plugin/roles/examples/process-owner.md +23 -0
  393. package/plugin/roles/examples/product-design.md +39 -0
  394. package/plugin/roles/examples/program-manager.md +24 -0
  395. package/plugin/roles/examples/quality.md +25 -0
  396. package/plugin/roles/examples/research-director.md +24 -0
  397. package/plugin/roles/examples/research-ops.md +25 -0
  398. package/plugin/roles/examples/researcher.md +25 -0
  399. package/plugin/roles/examples/revenue-lead.md +25 -0
  400. package/plugin/roles/examples/revops.md +26 -0
  401. package/plugin/roles/examples/seo.md +27 -0
  402. package/plugin/roles/examples/specialist.md +24 -0
  403. package/plugin/scripts/actualiza.py +198 -0
  404. package/plugin/scripts/admision.py +165 -0
  405. package/plugin/scripts/apaga.py +232 -0
  406. package/plugin/scripts/atajos.py +506 -0
  407. package/plugin/scripts/avatar.py +211 -0
  408. package/plugin/scripts/broker.py +531 -0
  409. package/plugin/scripts/cage.py +492 -0
  410. package/plugin/scripts/capabilities.py +210 -0
  411. package/plugin/scripts/card.py +322 -0
  412. package/plugin/scripts/cities.py +520 -0
  413. package/plugin/scripts/city-env.sh +90 -0
  414. package/plugin/scripts/city-runtime.sh +87 -0
  415. package/plugin/scripts/city-session.sh +552 -0
  416. package/plugin/scripts/city_env.py +67 -0
  417. package/plugin/scripts/crecimiento.py +140 -0
  418. package/plugin/scripts/deliberations.py +48 -0
  419. package/plugin/scripts/doctor.py +294 -0
  420. package/plugin/scripts/domains.py +201 -0
  421. package/plugin/scripts/evidencia.py +64 -0
  422. package/plugin/scripts/find-repos.sh +111 -0
  423. package/plugin/scripts/gh.py +159 -0
  424. package/plugin/scripts/hall_protocol.py +147 -0
  425. package/plugin/scripts/hook_activity.py +139 -0
  426. package/plugin/scripts/launch.py +94 -0
  427. package/plugin/scripts/logs.py +98 -0
  428. package/plugin/scripts/pairing.py +185 -0
  429. package/plugin/scripts/parcels.py +132 -0
  430. package/plugin/scripts/read-card.py +55 -0
  431. package/plugin/scripts/report.py +201 -0
  432. package/plugin/scripts/reset.py +180 -0
  433. package/plugin/scripts/roads.py +230 -0
  434. package/plugin/scripts/roles.py +274 -0
  435. package/plugin/scripts/runtime_log.py +69 -0
  436. package/plugin/scripts/runtime_processes.py +132 -0
  437. package/plugin/scripts/rutas.py +83 -0
  438. package/plugin/scripts/seat.py +1696 -0
  439. package/plugin/scripts/tokens.py +194 -0
  440. package/plugin/scripts/trust-repos.py +59 -0
  441. package/plugin/scripts/ui.py +280 -0
  442. package/plugin/scripts/units.py +80 -0
  443. package/plugin/scripts/workspace.py +561 -0
  444. package/plugin/skills/city/SKILL.md +191 -0
  445. package/templates/blank.md +39 -0
  446. package/templates/finance.md +44 -0
  447. package/templates/legal.md +45 -0
  448. package/templates/marketing.md +48 -0
  449. 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())