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