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,210 @@
1
+ #!/usr/bin/env python3
2
+ """Live, read-only recognition of skills available to a city's repo agents.
3
+
4
+ Agents City never installs, copies, enables or edits a skill. The runtime in a
5
+ repo remains the source of truth and decides whether a skill applies to a task.
6
+ This module only discovers conventional repo-local ``SKILL.md`` files so a seat
7
+ can see which agent is likely to know how to help.
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+
15
+ import card
16
+ import cities
17
+ import workspace
18
+
19
+
20
+ RAICES = (".claude/skills", ".codex/skills", ".agents/skills", "skills")
21
+
22
+
23
+ def _campo(texto, clave):
24
+ if not texto.startswith("---"):
25
+ return ""
26
+ partes = texto.split("---", 2)
27
+ if len(partes) < 3:
28
+ return ""
29
+ m = re.search(rf"^{re.escape(clave)}:[ \t]*(.+)$", partes[1], re.M)
30
+ return m.group(1).strip().strip("\"'") if m else ""
31
+
32
+
33
+ def descubre_repo(ruta_repo):
34
+ """Skills declared by this repo, with no cache and no writes."""
35
+ fuera, vistos = [], set()
36
+ raiz_manifest = os.path.join(ruta_repo, "SKILL.md")
37
+ if os.path.isfile(raiz_manifest):
38
+ try:
39
+ texto = open(raiz_manifest, encoding="utf-8").read()
40
+ except OSError:
41
+ texto = ""
42
+ if texto:
43
+ real = os.path.realpath(raiz_manifest)
44
+ vistos.add(real)
45
+ fuera.append(
46
+ {
47
+ "name": _campo(texto, "name") or os.path.basename(ruta_repo),
48
+ "description": _campo(texto, "description"),
49
+ "manifest": real,
50
+ "provider": "repo",
51
+ }
52
+ )
53
+ for relativa in RAICES:
54
+ base = os.path.join(ruta_repo, relativa)
55
+ if not os.path.isdir(base):
56
+ continue
57
+ for nombre in sorted(os.listdir(base)):
58
+ manifiesto = os.path.join(base, nombre, "SKILL.md")
59
+ if not os.path.isfile(manifiesto):
60
+ continue
61
+ real = os.path.realpath(manifiesto)
62
+ if real in vistos:
63
+ continue
64
+ vistos.add(real)
65
+ try:
66
+ texto = open(manifiesto, encoding="utf-8").read()
67
+ except OSError:
68
+ continue
69
+ fuera.append(
70
+ {
71
+ "name": _campo(texto, "name") or nombre,
72
+ "description": _campo(texto, "description"),
73
+ "manifest": real,
74
+ "provider": relativa.split("/", 1)[0].lstrip(".") or "repo",
75
+ }
76
+ )
77
+ return fuera
78
+
79
+
80
+ #: The disk index, remembered for a moment. `find-repos.sh` keeps its own
81
+ #: day-long cache, but SPAWNING it and re-parsing the TSV is ~15 ms, and one
82
+ #: Hall request resolves it once per legacy agent — N+1 processes for one
83
+ #: answer. A short memory collapses that to one while staying young enough to
84
+ #: notice a repo cloned a minute ago.
85
+ _INDICE = {"cuando": 0.0, "valor": None}
86
+
87
+
88
+ def _indice_repos(vida=90):
89
+ import time
90
+
91
+ if _INDICE["valor"] is not None and time.monotonic() - _INDICE["cuando"] < vida:
92
+ return _INDICE["valor"]
93
+ guion = os.path.join(os.path.dirname(__file__), "find-repos.sh")
94
+ try:
95
+ salida = subprocess.run([guion], capture_output=True, text=True, timeout=300).stdout
96
+ except (OSError, subprocess.TimeoutExpired):
97
+ return {}
98
+ fuera = {}
99
+ for linea in salida.splitlines():
100
+ if "\t" in linea:
101
+ nombre, ruta = linea.split("\t", 1)
102
+ fuera[nombre.strip()] = ruta.strip()
103
+ _INDICE["cuando"], _INDICE["valor"] = time.monotonic(), fuera
104
+ return fuera
105
+
106
+
107
+ def ficha_de_ciudad(datos):
108
+ owner = cities.lee_clave(datos, "owner")
109
+ preferida = os.path.join(datos, f"{owner}.md") if owner else ""
110
+ if preferida and os.path.isfile(preferida):
111
+ return preferida
112
+ try:
113
+ fichas = sorted(
114
+ os.path.join(datos, f)
115
+ for f in os.listdir(datos)
116
+ if f.endswith(".md") and card.lee(os.path.join(datos, f)).get("user")
117
+ )
118
+ except OSError:
119
+ fichas = []
120
+ return fichas[0] if fichas else ""
121
+
122
+
123
+ def ruta_de(nombre, rutas=None):
124
+ """One legacy repo's folder on this disk, or ''. The single mechanism both
125
+ full discovery and one-agent lookups share — resolving one path must never
126
+ cost a whole-city skill scan."""
127
+ rutas = _indice_repos() if rutas is None else rutas
128
+ code = os.path.expanduser(os.environ.get("CITY_CODE_DIR", "~/codigo"))
129
+ ruta = rutas.get(nombre, os.path.join(code, nombre))
130
+ return os.path.realpath(ruta) if os.path.isdir(ruta) else ""
131
+
132
+
133
+ def descubre_ciudad(datos, rutas=None):
134
+ """``{agent: {path, role, skills}}`` for the city's support agents.
135
+
136
+ Both card shapes, through the one normaliser (`workspace.agentes`): a
137
+ legacy repo agent looks for its repo on disk exactly as before, and an
138
+ agents-first agent looks in its own workspace and in every mount it
139
+ declares. Before this, an agents-first city honestly full of skills showed
140
+ "Agents & skills 0" — the discovery only knew `repos:`.
141
+ """
142
+ ficha = ficha_de_ciudad(datos)
143
+ if not ficha:
144
+ return {}
145
+ try:
146
+ texto = open(ficha, encoding="utf-8").read()
147
+ except OSError:
148
+ return {}
149
+ try:
150
+ agentes = workspace.agentes(texto, datos)
151
+ except ValueError:
152
+ # A malformed card (two names that slug to one identity) is the card
153
+ # tools' error to raise loudly; read-only discovery just has nothing
154
+ # safe to list for it.
155
+ return {}
156
+ if not agentes:
157
+ return {}
158
+ # The disk index is a find-repos scan: paid lazily, and only when a legacy
159
+ # agent actually needs it — a pure agents-first city never touches it.
160
+ fuera = {}
161
+ for a in agentes:
162
+ if a.legacy:
163
+ rutas = _indice_repos() if rutas is None else rutas
164
+ candidatos = [ruta_de(a.nombre, rutas)]
165
+ else:
166
+ candidatos = [a.workspace] + [os.path.expanduser(m) for m in a.mounts]
167
+ existentes = [c for c in candidatos if c and os.path.isdir(c)]
168
+ skills = []
169
+ vistos = set()
170
+ for c in existentes:
171
+ for s in descubre_repo(c):
172
+ if s["name"] in vistos:
173
+ continue
174
+ vistos.add(s["name"])
175
+ skills.append(s)
176
+ fuera[a.nombre] = {
177
+ "path": os.path.realpath(existentes[0]) if existentes else "",
178
+ "role": a.rol,
179
+ "skills": skills,
180
+ }
181
+ return fuera
182
+
183
+
184
+ def main():
185
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
186
+ print(
187
+ "usage: agents-city skills [city]\n\n"
188
+ "Read-only discovery of skills already installed in each repo."
189
+ )
190
+ return 0
191
+ usuario = cities.usuario_actual()
192
+ pedida = sys.argv[1] if len(sys.argv) > 1 else ""
193
+ datos = cities.resuelve(pedida, usuario) if pedida else cities.actual(usuario)
194
+ if not datos:
195
+ print(f" No city called {pedida!r}.", file=sys.stderr)
196
+ return 1
197
+ capacidades = descubre_ciudad(datos)
198
+ print(f" Skills visible to {cities.direccion(usuario, datos)}")
199
+ if not capacidades:
200
+ print(" No repo agents in this city.")
201
+ for repo, info in capacidades.items():
202
+ nombres = ", ".join(s["name"] for s in info["skills"]) or "none discovered"
203
+ estado = info["path"] or "repo not found on this machine"
204
+ print(f" {repo} [{info['role']}]: {nombres}\n {estado}")
205
+ print("\n Read-only discovery: Agents City installed and changed nothing.")
206
+ return 0
207
+
208
+
209
+ if __name__ == "__main__":
210
+ sys.exit(main())
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """The one reader and writer of a person's card.
3
+
4
+ A card is markdown with frontmatter. It is the only thing that says who somebody
5
+ is, what they answer for and what they are aiming at, and it is read by the tmux
6
+ session, the seat, the wizard, the repo picker, the seeder and every round.
7
+
8
+ There were five ways to read it and three to write it, in one repo:
9
+
10
+ * `read-card.py`, a CLI, one field at a time, by subprocess
11
+ * a repo picker with its own frontmatter parse, for `repos` only — which nothing
12
+ ever called, and which is gone
13
+ * `bin/seat`, all three of the above plus two loose regexes of its own
14
+ * `bin/setup.py` and `bin/seat` each writing the whole card their own way
15
+
16
+ That is how the seat window ended up reading a field called `agente` while every
17
+ card in the repo carries `agent`, and how a card written by one door came out
18
+ different from the same card written by the other. One file, so there is one answer.
19
+
20
+ Here, and not next to the wizard, for the same reason as `parcels.py`: this
21
+ directory is what gets installed on everybody's machine.
22
+ """
23
+ import re
24
+
25
+ # The frontmatter fields, in the order they are written. Order is not cosmetic — a
26
+ # card is read by people, and a stable order means a diff shows what changed rather
27
+ # than that something moved.
28
+ CAMPOS = ('user', 'name', 'role', 'agent', 'repos', 'goals_defined')
29
+ ROLE_ID = re.compile(r'^[a-z0-9][a-z0-9-]{0,63}$')
30
+ #: What `ventana()` can produce, as a shape callers can check without rebuilding
31
+ #: it. A window slug is longer than a role id (80 vs 64), and every door that
32
+ #: resolves an agent by slug has to agree on that or a long-named agent renders
33
+ #: everywhere and answers nowhere.
34
+ VENTANA_ID = re.compile(r'^[a-z0-9][a-z0-9-]{0,79}$')
35
+
36
+
37
+ def ventana_valida(valor):
38
+ """Whether this is a window slug the rest of the product will accept."""
39
+ return bool(VENTANA_ID.fullmatch(str(valor or '')))
40
+
41
+
42
+ def frontmatter(texto):
43
+ """The frontmatter block of a card, or '' if it has none."""
44
+ if not texto.startswith('---'):
45
+ return ''
46
+ partes = texto.split('---')
47
+ return partes[1] if len(partes) > 2 else ''
48
+
49
+
50
+ def lista(valor):
51
+ """`[a, "b", 'c']` -> ['a', 'b', 'c']. Quotes are optional in these files."""
52
+ return [x.strip().strip('"\'') for x in valor.strip().strip('[]').split(',') if x.strip()]
53
+
54
+
55
+ def campo(texto, clave, defecto=''):
56
+ """One frontmatter field, by name.
57
+
58
+ `agent` also answers to `agente`. Every card in this repo says `agent`, but a
59
+ hand-written one may not, and the version of this that only accepted `agente`
60
+ silently returned nothing for all twelve demo cards.
61
+ """
62
+ fm = frontmatter(texto)
63
+ if not fm:
64
+ return defecto
65
+ for nombre in ((clave, 'agente') if clave == 'agent' else (clave,)):
66
+ m = re.search(rf'^{re.escape(nombre)}:[ \t]*(.*)$', fm, re.M)
67
+ if m and m.group(1).strip():
68
+ return m.group(1).strip()
69
+ return defecto
70
+
71
+
72
+ def lee(ruta):
73
+ """A whole card as a dict, including its goal. Missing file reads as empty."""
74
+ try:
75
+ with open(ruta, encoding='utf-8') as f:
76
+ texto = f.read()
77
+ except (OSError, IsADirectoryError):
78
+ return {}
79
+ usuario = campo(texto, 'user')
80
+ repos = lista(campo(texto, 'repos'))
81
+ return {
82
+ 'user': usuario,
83
+ 'name': campo(texto, 'name', usuario),
84
+ 'role': campo(texto, 'role', 'dev'),
85
+ 'agent': campo(texto, 'agent'),
86
+ 'repos': repos,
87
+ 'repo_roles': roles_repos(texto, repos),
88
+ 'goals_defined': campo(texto, 'goals_defined') == 'true',
89
+ 'objetivo': objetivo(texto, usuario),
90
+ 'texto': texto,
91
+ }
92
+
93
+
94
+ def rol_seguro(valor, defecto='blank'):
95
+ """One inert role id suitable for a card key, environment and prompt.
96
+
97
+ Role text is configuration, never a shell fragment. A hand-edited malformed
98
+ value therefore degrades to the explicit blank role rather than crossing the
99
+ runtime boundary verbatim.
100
+ """
101
+ valor = str(valor or '').strip().lower()
102
+ return valor if ROLE_ID.fullmatch(valor) else defecto
103
+
104
+
105
+ def roles_repos(texto, repos=None):
106
+ """``{original repo name: operating role}`` from dynamic ``role.<actor>`` keys.
107
+
108
+ Old cards have no such keys. Returning ``blank`` is intentional: it preserves
109
+ the old behaviour without pretending that Agents City knew why that repo was
110
+ present. The key suffix uses the same canonical actor/window slug as tmux and
111
+ the bus.
112
+ """
113
+ repos = list(repos if repos is not None else lista(campo(texto, 'repos')))
114
+ return {repo: rol_seguro(campo(texto, f'role.{ventana(repo)}')) for repo in repos}
115
+
116
+
117
+ def normaliza_roles_repos(repos, asignados=None):
118
+ """Validate one explicit role per selected repo and reject actor collisions."""
119
+ asignados = dict(asignados or {})
120
+ fuera, actores = {}, {}
121
+ limpios = [str(r).strip() for r in repos if str(r).strip()]
122
+ for repo in sorted(dict.fromkeys(limpios)):
123
+ actor = ventana(repo)
124
+ if actor in actores and actores[actor] != repo:
125
+ raise ValueError(
126
+ f"repo names {actores[actor]!r} and {repo!r} collide as agent {actor!r}"
127
+ )
128
+ actores[actor] = repo
129
+ crudo = asignados.get(repo, asignados.get(actor, 'blank'))
130
+ rol = str(crudo or 'blank').strip().lower()
131
+ if not ROLE_ID.fullmatch(rol):
132
+ raise ValueError(f"invalid role {crudo!r} for repo {repo!r}")
133
+ fuera[repo] = rol
134
+ return fuera
135
+
136
+
137
+ def linea_rol(rol, oficio=''):
138
+ """The one human-readable role line shared by new cards and surgery."""
139
+ if rol == 'blank':
140
+ return 'Role: **blank** — no predefined seat responsibility or role knowledge.'
141
+ nombre = (oficio or rol.replace('-', ' ')).lower()
142
+ return (
143
+ f'Role: **{rol}** — in the city, the **{nombre}**. '
144
+ f'Role knowledge, when present: `roles/{rol}.md`.'
145
+ )
146
+
147
+
148
+ def objetivo(texto, usuario=''):
149
+ """The first goal off a card, in the shape both writers emit."""
150
+ if '### O1 —' not in texto:
151
+ return None
152
+ m = re.search(r'^### O1 — (.+)$', texto, re.M)
153
+ titulo = m.group(1).strip() if m else ''
154
+ if not titulo:
155
+ return None
156
+
157
+ def linea(etiqueta, defecto=''):
158
+ m = re.search(rf'^- \*\*{etiqueta}\*\*:\s*`?(.+?)`?\.?$', texto, re.M)
159
+ return m.group(1).strip() if m else defecto
160
+
161
+ medida = linea('Measure')
162
+ # A measure is one of two honest things: a command that returns a number, or a
163
+ # person's judgement written down — "the architect reads the AGENTS.md files on
164
+ # Fridays". The second is not a lesser goal; plenty of quality is prose. It
165
+ # round-trips as `manual`, never as a command a round might try to execute.
166
+ manual = ''
167
+ if medida.startswith('manual —'):
168
+ manual = medida[len('manual —'):].strip()
169
+ if manual == 'who looks at what, and how often':
170
+ manual = ''
171
+ medida = ''
172
+ return {'user': usuario, 'title': titulo,
173
+ 'signal': linea('How it is measured'),
174
+ 'command': medida, 'manual': manual,
175
+ 'baseline': linea('Baseline'), 'target': linea('Target'),
176
+ 'by': linea('By when', 'this quarter')}
177
+
178
+
179
+ def bloque_objetivo(obj):
180
+ """The goal, as the lines that go on a card. One emitter, so a round reads the
181
+ same shape whichever door wrote it."""
182
+ if not obj:
183
+ return ['> **Pending.** A round works without them, but it cannot contrast '
184
+ 'the work against anything.', '',
185
+ '> To set one: `./bin/seat --goal`, or `/city:goals`.', '']
186
+ medida = obj.get('command') or ('manual — ' + (
187
+ obj.get('manual') or 'who looks at what, and how often'))
188
+ return [f"### O1 — {obj['title']}",
189
+ f"- **What**: {obj['title']}.",
190
+ f"- **How it is measured**: {obj.get('signal') or 'to be defined'}.",
191
+ f"- **Measure**: `{medida}`",
192
+ f"- **Baseline**: {obj.get('baseline') or 'not measured yet'}",
193
+ f"- **Target**: {obj.get('target') or 'to be defined'}",
194
+ f"- **By when**: {obj.get('by') or 'this quarter'}",
195
+ '- **State**: in progress', '']
196
+
197
+
198
+ def _cambia_seccion(texto, titulo, lineas_nuevas):
199
+ """Replace one `##` section's body, touching nothing else on the card."""
200
+ patron = re.compile(rf'(^## {re.escape(titulo)}\n)(.*?)(?=^## |\Z)', re.M | re.S)
201
+ if not patron.search(texto):
202
+ return None
203
+ cuerpo = '\n' + '\n'.join(lineas_nuevas).rstrip('\n') + '\n\n'
204
+ return patron.sub(lambda m: m.group(1) + cuerpo, texto, count=1)
205
+
206
+
207
+ def cambia_objetivo(ruta, obj):
208
+ """Rewrite only the goals section — and the `goals_defined` flag with it.
209
+
210
+ Surgical on purpose: the card also carries the round history, and the first
211
+ version of "change your goal" rewrote the whole card from a template, wiping
212
+ every round summary anyone had left there. Changing what you aim at must not
213
+ erase what already happened.
214
+
215
+ `obj` of None clears the goal back to pending. Returns False when the card has
216
+ no goals section to replace.
217
+ """
218
+ with open(ruta, encoding='utf-8') as f:
219
+ texto = f.read()
220
+ nuevo = _cambia_seccion(texto, 'Current goals', bloque_objetivo(obj))
221
+ if nuevo is None:
222
+ return False
223
+ bandera = 'true' if obj else 'false'
224
+ nuevo = re.sub(r'^goals_defined:.*$', f'goals_defined: {bandera}', nuevo,
225
+ count=1, flags=re.M)
226
+ with open(ruta, 'w', encoding='utf-8') as f:
227
+ f.write(nuevo)
228
+ return True
229
+
230
+
231
+ def cambia_rol(ruta, rol, agente, oficio=''):
232
+ """Rewrite the role — frontmatter, agent name, and the one body line that says
233
+ it out loud, so the card does not end up claiming two different roles."""
234
+ with open(ruta, encoding='utf-8') as f:
235
+ texto = f.read()
236
+ nuevo, n = re.subn(r'^role:.*$', f'role: {rol}', texto, count=1, flags=re.M)
237
+ if n == 0:
238
+ return False
239
+ nuevo = re.sub(r'^agent:.*$', f'agent: {agente}', nuevo, count=1, flags=re.M)
240
+ if oficio or rol == 'blank':
241
+ nuevo = re.sub(r'^Role:.*$', linea_rol(rol, oficio), nuevo, count=1, flags=re.M)
242
+ with open(ruta, 'w', encoding='utf-8') as f:
243
+ f.write(nuevo)
244
+ return True
245
+
246
+
247
+ def ventana(repo):
248
+ """The tmux window a repo becomes — which doubles as the engine-key suffix
249
+ on the card (`model.<window>`, `runs.<window>`), so the charset stays
250
+ key-safe and valid as a bus actor. A worktree `MiApp@feature/X` becomes
251
+ `miapp-feature-x`. Shell callers delegate here through read-card.py."""
252
+ limpio = re.sub(r'[^a-z0-9-]+', '-', str(repo).lower()).strip('-')
253
+ return (limpio or 'repo')[:80]
254
+
255
+
256
+ def pon_campo(ruta, clave, valor):
257
+ """Set one frontmatter field, surgically: replace it if it exists, add it just
258
+ before the closing --- if not, remove it when the value is empty.
259
+
260
+ This is how a card carries the engine its agents start with — `model:` and
261
+ `effort:` as the person's default, `model.<window>:` / `effort.<window>:` to
262
+ tune one window ("the docs repo runs on haiku, the monorepo on opus"). The
263
+ session script resolves specific-then-default, and an absent key means
264
+ "whatever this person's Claude defaults to", which is the right silence.
265
+ """
266
+ if not re.match(r'^[a-z][a-z0-9._-]*$', clave):
267
+ return False
268
+ with open(ruta, encoding='utf-8') as f:
269
+ texto = f.read()
270
+ if not texto.startswith('---'):
271
+ return False
272
+ patron = re.compile(rf'^{re.escape(clave)}:.*\n', re.M)
273
+ fin = texto.index('---', 3)
274
+ if valor == '' or valor is None:
275
+ nuevo = patron.sub('', texto[:fin], count=1) + texto[fin:]
276
+ elif patron.search(texto[:fin]):
277
+ nuevo = patron.sub(f'{clave}: {valor}\n', texto[:fin], count=1) + texto[fin:]
278
+ else:
279
+ nuevo = texto[:fin] + f'{clave}: {valor}\n' + texto[fin:]
280
+ with open(ruta, 'w', encoding='utf-8') as f:
281
+ f.write(nuevo)
282
+ return True
283
+
284
+
285
+ def cambia_repos(ruta, elegidos):
286
+ """Rewrite only the `repos` field of an existing card, leaving the rest alone.
287
+
288
+ Used by the repo picker, which has no business rewriting somebody's goals to
289
+ change which folders they answer for.
290
+ """
291
+ with open(ruta, encoding='utf-8') as f:
292
+ texto = f.read()
293
+ linea = 'repos: [' + ', '.join(sorted(elegidos)) + ']'
294
+ nuevo, n = re.subn(r'^repos:.*$', linea, texto, count=1, flags=re.M)
295
+ if n == 0:
296
+ return False
297
+ with open(ruta, 'w', encoding='utf-8') as f:
298
+ f.write(nuevo)
299
+ return True
300
+
301
+
302
+ def cambia_roles_repos(ruta, elegidos, asignados):
303
+ """Atomically persist one explicit operating role per selected repo.
304
+
305
+ Stale ``role.<actor>`` keys are removed when a repo leaves the city. Other
306
+ per-window settings (model, effort and runtime) remain untouched.
307
+ """
308
+ roles = normaliza_roles_repos(elegidos, asignados)
309
+ with open(ruta, encoding='utf-8') as f:
310
+ texto = f.read()
311
+ if not texto.startswith('---'):
312
+ return False
313
+ fin = texto.find('---', 3)
314
+ if fin < 0:
315
+ return False
316
+ cabeza = re.sub(r'^role\.[a-z0-9._-]+:.*\n', '', texto[:fin], flags=re.M)
317
+ lineas = ''.join(f'role.{ventana(repo)}: {rol}\n' for repo, rol in roles.items())
318
+ if lineas:
319
+ cabeza += lineas
320
+ with open(ruta, 'w', encoding='utf-8') as f:
321
+ f.write(cabeza + texto[fin:])
322
+ return True