agents-city 0.3.0-beta.21

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