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,201 @@
1
+ #!/usr/bin/env python3
2
+ """Report growth from where the work actually happens.
3
+
4
+ The city's cron can count merged pull requests by itself. It cannot run a command
5
+ inside your folders — and in a marketing, legal or finance city, growth is
6
+ whatever a command in that folder returns: pieces published, documents filed,
7
+ periods closed.
8
+
9
+ So this runs where the folders are, once a day is plenty, and reports the totals.
10
+ The city works out what changed today.
11
+
12
+ ./bin/report.py show what it would send
13
+ ./bin/report.py --push send it
14
+ ./bin/report.py --data ../mine read a different data repo
15
+
16
+ Nothing to run by hand in the normal case: the plugin reports once a day from a
17
+ Stop hook. This is here for the first look, and for a backfill.
18
+
19
+ It needs your bus token, because the city does not take numbers from anonymous
20
+ callers: `CITY_BUS_TOKEN`, or the plugin's keychain entry, or `--token`.
21
+ """
22
+ import argparse
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ import urllib.request
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+ import city_env
32
+ import parcels # the one reader of parcels.yml, shared with the seeder
33
+
34
+
35
+ def lee(fichero, clave, defecto=''):
36
+ try:
37
+ t = open(fichero).read()
38
+ except FileNotFoundError:
39
+ return defecto
40
+ m = re.search(rf'^{clave}:\s*(.+)$', t, re.M)
41
+ return m.group(1).strip() if m else defecto
42
+
43
+
44
+ _DONDE = {}
45
+
46
+
47
+ def donde_esta(repo):
48
+ """The local folder of a repo, by the name on its remote.
49
+
50
+ `parcels.yml` deliberately holds no local paths: the same file is shared by a
51
+ whole team and the same repo sits somewhere different on every machine. That is
52
+ what find-repos.sh is for, and it caches its index for a day.
53
+ """
54
+ if repo not in _DONDE:
55
+ guion = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'find-repos.sh')
56
+ try:
57
+ r = subprocess.run([guion, repo], capture_output=True, text=True, timeout=300)
58
+ _DONDE[repo] = r.stdout.strip()
59
+ except (OSError, subprocess.TimeoutExpired):
60
+ _DONDE[repo] = ''
61
+ return _DONDE[repo]
62
+
63
+
64
+ def parcelas(datos):
65
+ """Every parcel, with the folder to run its grow command in.
66
+
67
+ The format is read by `parcels.py`, which the seeder reads too. All this adds is
68
+ the one thing that is local to this machine: where the folder actually is.
69
+
70
+ The folder used to be read as `c.get('path')` — a key this format does not have
71
+ and no writer has ever written; it is `ruta`. So it was always empty, every
72
+ parcel was skipped as folderless, and this script could not report a single
73
+ number for anybody. The message it printed sent you to add a `path:` that means
74
+ nothing.
75
+
76
+ `ruta` is a glob *inside* the repo, not a path to it. A concrete subdirectory
77
+ becomes the folder; a glob, or an empty one, means the whole repo, because there
78
+ is no single directory a pattern points at.
79
+ """
80
+ declaradas, _, raras = parcels.lee(f'{datos}/parcels.yml')
81
+ if raras:
82
+ print(parcels.aviso(raras), file=sys.stderr)
83
+ fuera = []
84
+ for p in declaradas:
85
+ base = donde_esta(p['repo'])
86
+ carpeta = base
87
+ if base and p['ruta'] and not any(c in p['ruta'] for c in '*?['):
88
+ dentro = os.path.join(base, p['ruta'])
89
+ if os.path.isdir(dentro):
90
+ carpeta = dentro
91
+ fuera.append(dict(p, path=carpeta))
92
+ return fuera
93
+
94
+
95
+ def cuenta(comando, cwd):
96
+ """Run the grow command and take the first number it prints.
97
+
98
+ A command that fails counts as *unknown*, not as zero. Reporting zero because
99
+ a script broke would look like work being undone, which is worse than a gap.
100
+ """
101
+ if not comando:
102
+ return None
103
+ try:
104
+ r = subprocess.run(comando, shell=True, cwd=cwd, capture_output=True,
105
+ text=True, timeout=120)
106
+ except subprocess.TimeoutExpired:
107
+ return None
108
+ if r.returncode != 0:
109
+ return None
110
+ m = re.search(r'-?\d+', r.stdout.replace(',', ''))
111
+ return int(m.group()) if m else None
112
+
113
+
114
+ def mide(datos, por_defecto, todas):
115
+ """Run each parcel's grow command where it lives. A command that fails is
116
+ *unknown*, never zero — zero would look like work being undone."""
117
+ filas, sin_medir = [], []
118
+ for p in todas:
119
+ carpeta = os.path.expanduser(p['path']) if p['path'] else None
120
+ if not carpeta or not os.path.isdir(carpeta):
121
+ continue
122
+ # The folder's own descriptor wins over the city default: a parcel knows
123
+ # better than the city how it is counted.
124
+ cmd = lee(os.path.join(carpeta, '.city.yml'), 'grow_command', por_defecto)
125
+ n = cuenta(cmd, carpeta)
126
+ if n is None:
127
+ sin_medir.append(p['id'])
128
+ else:
129
+ filas.append({'id': p['id'], 'floors': n})
130
+ return filas, sin_medir
131
+
132
+
133
+ def explica_vacio(di, datos, todas, por_defecto):
134
+ """Nothing measured: say which of the three reasons it is, precisely. The
135
+ version before this printed advice about a field that does not exist."""
136
+ perdidas = [p['repo'] for p in todas if not p['path']]
137
+ di(f'Nothing to report from {datos}.')
138
+ if not todas:
139
+ di('There are no parcels in parcels.yml yet. ./bin/seat writes one per '
140
+ 'folder you pick.')
141
+ elif perdidas:
142
+ di(f'{len(perdidas)} of {len(todas)} parcels are not cloned on this '
143
+ f'machine, so there is no folder to run anything in:')
144
+ di(' ' + ', '.join(sorted(set(perdidas))[:10]))
145
+ di('Clone them, or run ./plugin/scripts/find-repos.sh --refresh if they '
146
+ 'are here under another folder name.')
147
+ elif not por_defecto:
148
+ di('The folders are here, but nothing says how growth is counted. Put '
149
+ '`grow_command` in city.yml, or a `.city.yml` in each folder.')
150
+
151
+
152
+ def main():
153
+ ap = argparse.ArgumentParser()
154
+ ap.add_argument('--data', default=city_env.datos())
155
+ ap.add_argument('--url', default=city_env.url())
156
+ ap.add_argument('--token', default='')
157
+ ap.add_argument('--push', action='store_true')
158
+ ap.add_argument('--quiet', action='store_true', help='say nothing unless it fails')
159
+ a = ap.parse_args()
160
+ di = (lambda *x, **k: None) if a.quiet else print
161
+ datos = os.path.expanduser(a.data)
162
+
163
+ por_defecto = lee(f'{datos}/city.yml', 'grow_command')
164
+ todas = parcelas(datos)
165
+ filas, sin_medir = mide(datos, por_defecto, todas)
166
+
167
+ if not filas and not sin_medir:
168
+ explica_vacio(di, datos, todas, por_defecto)
169
+ return 0
170
+
171
+ for f in filas:
172
+ di(f" {f['id']:<34} {f['floors']}")
173
+ for s in sin_medir:
174
+ di(f' {s:<34} — could not measure (command failed or returned nothing)')
175
+
176
+ if not a.push:
177
+ di('\nNothing sent. Add --push when it looks right.')
178
+ return 0
179
+ if not a.url:
180
+ print('\nNo city URL. Pass --url or set AGENTS_CITY_URL.', file=sys.stderr)
181
+ return 1
182
+ tk = city_env.token(a.token)
183
+ if not tk:
184
+ print('\nNo bus token. Pass --token or set CITY_BUS_TOKEN.', file=sys.stderr)
185
+ return 1
186
+
187
+ req = urllib.request.Request(
188
+ a.url.rstrip('/') + '/api/growth',
189
+ data=json.dumps({'parcels': filas}).encode(),
190
+ headers={'content-type': 'application/json', 'authorization': f'Bearer {tk}'})
191
+ try:
192
+ with urllib.request.urlopen(req, timeout=60) as r:
193
+ di('\n' + r.read().decode())
194
+ except Exception as e:
195
+ print(f'\nThe city refused it: {e}', file=sys.stderr)
196
+ return 1
197
+ return 0
198
+
199
+
200
+ if __name__ == '__main__':
201
+ sys.exit(main())
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """Reset one managed city to first-run state, recoverably.
3
+
4
+ The target is always explicit. Repos are references and are never touched. The
5
+ city folder and its local map state move to a timestamped backup, incident local
6
+ roads are removed at both ends, and an empty city with the same stable identity
7
+ is created in its place. The next ``agents-city seat --city <name>`` runs the
8
+ five onboarding questions again.
9
+ """
10
+
11
+ import argparse
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+ import cities
19
+ import domains
20
+ import roads
21
+ import runtime_processes
22
+
23
+
24
+ def _backup_path(usuario, datos):
25
+ sello = time.strftime("%Y%m%d-%H%M%S")
26
+ base = os.path.join(cities.carpeta_usuario(usuario), ".backups")
27
+ nombre = f"{cities.slug_ciudad(datos)}-{sello}"
28
+ ruta = os.path.join(base, nombre)
29
+ n = 2
30
+ while os.path.exists(ruta):
31
+ ruta = os.path.join(base, f"{nombre}-{n}")
32
+ n += 1
33
+ return ruta
34
+
35
+
36
+ def _deten(datos, dry_run=False):
37
+ guion = os.path.join(os.path.dirname(__file__), "apaga.py")
38
+ args = [sys.executable, guion, datos]
39
+ if dry_run:
40
+ args.append("--dry-run")
41
+ return subprocess.run(args, capture_output=True, text=True)
42
+
43
+
44
+ def _valida(datos, usuario):
45
+ usuario = usuario or cities.usuario_actual()
46
+ datos = os.path.realpath(datos)
47
+ if not cities.gestionada(datos, usuario):
48
+ raise ValueError(
49
+ "reset only accepts a managed city under "
50
+ f"{cities.carpeta_usuario(usuario)}; external/shared "
51
+ "folders are never erased"
52
+ )
53
+ if not cities.es_ciudad(datos):
54
+ raise ValueError(f"{datos} is not a city")
55
+ owner_en_disco = cities.lee_clave(datos, "owner")
56
+ if owner_en_disco and owner_en_disco != usuario:
57
+ raise ValueError(f"this city belongs to {owner_en_disco!r}, not {usuario!r}")
58
+ return datos, usuario
59
+
60
+
61
+ def _contexto(datos, usuario):
62
+ return {
63
+ "datos": datos,
64
+ "usuario": usuario,
65
+ "ident": cities.identidad(datos),
66
+ "nombre": cities.nombre(datos),
67
+ "slug": cities.slug_ciudad(datos),
68
+ "owner": cities.lee_clave(datos, "owner") or usuario,
69
+ "domain": domains.de_ciudad(datos),
70
+ "grow": cities.lee_clave(
71
+ datos, "grow_command", "git log --since=30.days --oneline | wc -l"
72
+ ),
73
+ "backup": _backup_path(usuario, datos),
74
+ "estado": os.path.join(cities.raiz(), "state", cities.slug(datos)),
75
+ "runtime": runtime_processes.ruta(datos),
76
+ "roads": roads.lee(datos),
77
+ }
78
+
79
+
80
+ def _preview(ctx):
81
+ print(f" Would reset city `{ctx['slug']}` at {ctx['datos']}")
82
+ print(f" Would preserve it at {ctx['backup']}")
83
+ print(" Would leave every referenced repo untouched")
84
+ if ctx["roads"]:
85
+ print(f" Would close {len(ctx['roads'])} road(s) at both ends")
86
+ parada = _deten(ctx["datos"], dry_run=True)
87
+ if parada.stdout.strip():
88
+ print(parada.stdout.rstrip())
89
+
90
+
91
+ def _para(datos):
92
+ parada = _deten(datos)
93
+ if parada.returncode != 0:
94
+ detalle = (parada.stderr or parada.stdout or "unknown stop error").strip()
95
+ raise ValueError(f"could not stop the city before reset: {detalle}")
96
+
97
+
98
+ def _cierra_carreteras(ctx):
99
+ """Back up and remove the other half of every incident local road."""
100
+ backlinks = os.path.join(ctx["backup"], "road-backlinks")
101
+ for camino in ctx["roads"]:
102
+ otro = roads.destino_local(camino, ctx["usuario"])
103
+ if not otro:
104
+ continue
105
+ os.makedirs(backlinks, exist_ok=True)
106
+ if os.path.isfile(roads.ruta(otro)):
107
+ destino = os.path.join(backlinks, f"{cities.slug_ciudad(otro)}.roads.json")
108
+ shutil.copy2(roads.ruta(otro), destino)
109
+ roads.desconecta(otro, ctx["ident"])
110
+
111
+
112
+ def _mueve_si_existe(origen, destino):
113
+ if os.path.exists(origen):
114
+ shutil.move(origen, destino)
115
+
116
+
117
+ def _respalda(ctx):
118
+ os.makedirs(ctx["backup"], exist_ok=False)
119
+ _cierra_carreteras(ctx)
120
+ shutil.move(ctx["datos"], os.path.join(ctx["backup"], "city"))
121
+ _mueve_si_existe(ctx["estado"], os.path.join(ctx["backup"], "state"))
122
+ _mueve_si_existe(ctx["runtime"], os.path.join(ctx["backup"], "runtime"))
123
+
124
+
125
+ def _crea_vacia(ctx):
126
+ datos = ctx["datos"]
127
+ os.makedirs(datos, exist_ok=True)
128
+ cities.asegura_metadata(datos, ctx["owner"], ctx["nombre"], ctx["ident"])
129
+ # Keep stable identity and growth measure, but no seat, agents, repos or roads.
130
+ city_yml = os.path.join(datos, "city.yml")
131
+ texto = open(city_yml, encoding="utf-8").read()
132
+ if "\ndomain:" not in "\n" + texto:
133
+ texto += f"domain: {ctx['domain']}\n"
134
+ if "\ngrow_command:" not in "\n" + texto:
135
+ texto += f"grow_command: {ctx['grow']}\n"
136
+ cities.escribe_atomico(city_yml, texto)
137
+ roads.escribe(datos, [])
138
+ cities.selecciona(ctx["usuario"], datos)
139
+
140
+
141
+ def reinicia(datos, usuario="", dry_run=False):
142
+ datos, usuario = _valida(datos, usuario)
143
+ ctx = _contexto(datos, usuario)
144
+ if dry_run:
145
+ _preview(ctx)
146
+ return ctx["backup"]
147
+
148
+ _para(datos)
149
+ _respalda(ctx)
150
+ _crea_vacia(ctx)
151
+ return ctx["backup"]
152
+
153
+
154
+ def main():
155
+ ap = argparse.ArgumentParser(
156
+ description="Reset one city to onboarding; repos stay untouched and data is backed up"
157
+ )
158
+ ap.add_argument("city", help="city name or path (required)")
159
+ ap.add_argument("--dry-run", action="store_true", help="show every effect, change nothing")
160
+ a = ap.parse_args()
161
+ usuario = cities.usuario_actual()
162
+ datos = cities.resuelve(a.city, usuario)
163
+ if not datos:
164
+ conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none"
165
+ print(f" No city called {a.city!r}. Known here: {conocidas}.", file=sys.stderr)
166
+ return 1
167
+ try:
168
+ backup = reinicia(datos, usuario, a.dry_run)
169
+ except ValueError as e:
170
+ print(f" {e}", file=sys.stderr)
171
+ return 1
172
+ if not a.dry_run:
173
+ corto = backup.replace(os.path.expanduser("~"), "~")
174
+ print(f" City `{cities.slug_ciudad(datos)}` reset. Recovery copy: {corto}")
175
+ print(f" Run `agents-city seat --city {cities.slug_ciudad(datos)}` for onboarding.")
176
+ return 0
177
+
178
+
179
+ if __name__ == "__main__":
180
+ sys.exit(main())
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """Explicit roads between autonomous cities.
3
+
4
+ Roads connect city seats, never users and never repo windows. A local road is
5
+ written at both ends. A remote road is added from a small public invitation;
6
+ the other end accepts this city's invitation independently. No token or skill
7
+ content is ever stored here.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+
15
+ import card
16
+ import cities
17
+ import domains
18
+
19
+
20
+ ADDRESS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}/[a-z0-9][a-z0-9_-]{0,63}$")
21
+ ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
22
+ INVITATION_KEYS = {"version", "id", "name", "owner", "address", "domain", "role"}
23
+
24
+
25
+ def _limpia(camino):
26
+ """Validate untrusted road metadata and return its storage shape."""
27
+ if not isinstance(camino, dict):
28
+ return None
29
+ ident = str(camino.get("id", "")).strip()
30
+ address = str(camino.get("address", "")).strip().lower()
31
+ if not ID_RE.fullmatch(ident) or not ADDRESS_RE.fullmatch(address):
32
+ return None
33
+ dueno, slug = address.split("/", 1)
34
+ owner = str(camino.get("owner") or dueno).strip().lower()
35
+ if owner != dueno:
36
+ return None
37
+ name = " ".join(str(camino.get("name") or slug).split())[:80] or slug
38
+ limpio = {"id": ident, "name": name, "owner": owner, "address": address}
39
+ dominio = domains.canonico(camino.get("domain"))
40
+ rol = str(camino.get("role") or "").strip().lower()
41
+ if dominio and re.fullmatch(r"[a-z0-9-]{1,80}", dominio):
42
+ limpio["domain"] = dominio
43
+ if rol and re.fullmatch(r"[a-z0-9-]{1,80}", rol):
44
+ limpio["role"] = rol
45
+ if camino.get("local"):
46
+ limpio["local"] = True
47
+ return limpio
48
+
49
+
50
+ def ruta(datos):
51
+ return os.path.join(os.path.realpath(datos), "roads.json")
52
+
53
+
54
+ def _atomico(ruta_, objeto):
55
+ os.makedirs(os.path.dirname(ruta_), exist_ok=True)
56
+ temporal = ruta_ + f".tmp-{os.getpid()}"
57
+ with open(temporal, "w", encoding="utf-8") as f:
58
+ json.dump(objeto, f, indent=2, ensure_ascii=False, sort_keys=True)
59
+ f.write("\n")
60
+ os.replace(temporal, ruta_)
61
+
62
+
63
+ def lee(datos):
64
+ try:
65
+ objeto = json.load(open(ruta(datos), encoding="utf-8"))
66
+ except (OSError, json.JSONDecodeError, TypeError):
67
+ return []
68
+ caminos = objeto.get("roads", []) if isinstance(objeto, dict) else []
69
+ return [limpio for r in caminos if (limpio := _limpia(r)) is not None]
70
+
71
+
72
+ def escribe(datos, caminos):
73
+ limpios, ids, direcciones = [], set(), set()
74
+ for camino in caminos:
75
+ limpio = _limpia(camino)
76
+ if not limpio or limpio["id"] in ids or limpio["address"] in direcciones:
77
+ continue
78
+ ids.add(limpio["id"])
79
+ direcciones.add(limpio["address"])
80
+ limpios.append(limpio)
81
+ limpios.sort(key=lambda r: (r["owner"], r["name"].lower(), r["id"]))
82
+ _atomico(ruta(datos), {"version": 1, "roads": limpios})
83
+
84
+
85
+ def invitacion(datos, usuario=""):
86
+ usuario = usuario or cities.usuario_actual()
87
+ owner = cities.lee_clave(datos, "owner") or usuario
88
+ ficha = card.lee(os.path.join(datos, f"{owner}.md"))
89
+ invitacion_ = {
90
+ "version": 1,
91
+ "id": cities.identidad(datos),
92
+ "name": cities.nombre(datos),
93
+ "owner": owner,
94
+ "address": cities.direccion(usuario, datos),
95
+ "domain": domains.de_ciudad(datos),
96
+ }
97
+ if ficha.get("role"):
98
+ invitacion_["role"] = ficha["role"]
99
+ return invitacion_
100
+
101
+
102
+ def _pon(datos, destino, local=False):
103
+ caminos = [r for r in lee(datos) if r["id"] != destino["id"]]
104
+ nuevo = dict(destino)
105
+ if local:
106
+ nuevo["local"] = True
107
+ caminos.append(nuevo)
108
+ escribe(datos, caminos)
109
+
110
+
111
+ def conecta(origen, destino, usuario=""):
112
+ """Connect two cities on this machine, symmetrically."""
113
+ usuario = usuario or cities.usuario_actual()
114
+ if cities.identidad(origen) == cities.identidad(destino):
115
+ raise ValueError("a city cannot build a road to itself")
116
+ _pon(origen, invitacion(destino, usuario), local=True)
117
+ _pon(destino, invitacion(origen, usuario), local=True)
118
+
119
+
120
+ def conecta_invitacion(origen, destino):
121
+ """Add the local half of a road to a remote city invitation."""
122
+ necesarios = {"id", "name", "owner", "address"}
123
+ if not isinstance(destino, dict) or not necesarios <= set(destino):
124
+ raise ValueError("the invitation is missing id, name, owner or address")
125
+ if not set(destino) <= INVITATION_KEYS:
126
+ raise ValueError("the invitation contains fields other than public city identity")
127
+ limpia = _limpia(destino)
128
+ if not limpia:
129
+ raise ValueError("the invitation has invalid or inconsistent identity")
130
+ origen_publico = invitacion(origen)
131
+ if limpia["id"] == origen_publico["id"] or limpia["address"] == origen_publico["address"]:
132
+ raise ValueError("a city cannot build a road to itself")
133
+ _pon(origen, limpia, local=False)
134
+
135
+
136
+ def desconecta(origen, destino_id):
137
+ antes = lee(origen)
138
+ despues = [r for r in antes if r["id"] != destino_id]
139
+ escribe(origen, despues)
140
+ return len(antes) != len(despues)
141
+
142
+
143
+ def desconecta_local(origen, destino):
144
+ a = desconecta(origen, cities.identidad(destino))
145
+ b = desconecta(destino, cities.identidad(origen))
146
+ return a or b
147
+
148
+
149
+ def quita_incidentes(datos, usuario=""):
150
+ """Remove every local edge touching a city; used by a scoped reset."""
151
+ usuario = usuario or cities.usuario_actual()
152
+ ident = cities.identidad(datos)
153
+ for otra in cities.lista(usuario):
154
+ if os.path.realpath(otra["ruta"]) != os.path.realpath(datos):
155
+ desconecta(otra["ruta"], ident)
156
+ escribe(datos, [])
157
+
158
+
159
+ def destino_local(camino, usuario=""):
160
+ """Resolve a road record to a city currently present on this machine."""
161
+ if not camino.get("local"):
162
+ return ""
163
+ usuario = usuario or cities.usuario_actual()
164
+ return next((c["ruta"] for c in cities.lista(usuario) if c["id"] == camino.get("id")), "")
165
+
166
+
167
+ def _resuelve(nombre, usuario):
168
+ datos = cities.resuelve(nombre, usuario)
169
+ if not datos:
170
+ conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none"
171
+ raise ValueError(f"no city called {nombre!r}; known here: {conocidas}")
172
+ return datos
173
+
174
+
175
+ def _ayuda():
176
+ return """usage:
177
+ agents-city road list <city>
178
+ agents-city road connect <city> <local-city|invitation.json>
179
+ agents-city road disconnect <city> <local-city|city-id>
180
+ agents-city road invite <city>
181
+
182
+ Roads are explicit. Connecting two local cities writes both ends. For a remote
183
+ city, exchange the public JSON printed by `road invite`; it contains no token."""
184
+
185
+
186
+ def main():
187
+ args = sys.argv[1:]
188
+ if not args or args[0] in ("-h", "--help", "help"):
189
+ print(_ayuda())
190
+ return 0
191
+ usuario = cities.usuario_actual()
192
+ try:
193
+ if args[0] == "list" and len(args) == 2:
194
+ origen = _resuelve(args[1], usuario)
195
+ caminos = lee(origen)
196
+ if not caminos:
197
+ print(f" {cities.nombre(origen)} has no roads.")
198
+ for r in caminos:
199
+ local = "local" if destino_local(r, usuario) else "remote"
200
+ print(f" {r['name']} {r['address']} [{local}]")
201
+ return 0
202
+ if args[0] == "invite" and len(args) == 2:
203
+ print(json.dumps(invitacion(_resuelve(args[1], usuario)), indent=2))
204
+ return 0
205
+ if args[0] == "connect" and len(args) == 3:
206
+ origen = _resuelve(args[1], usuario)
207
+ destino = cities.resuelve(args[2], usuario)
208
+ if destino:
209
+ conecta(origen, destino, usuario)
210
+ print(f" Road open: {cities.nombre(origen)} <-> {cities.nombre(destino)}")
211
+ else:
212
+ with open(os.path.expanduser(args[2]), encoding="utf-8") as f:
213
+ conecta_invitacion(origen, json.load(f))
214
+ print(f" Remote road added to {cities.nombre(origen)}.")
215
+ return 0
216
+ if args[0] == "disconnect" and len(args) == 3:
217
+ origen = _resuelve(args[1], usuario)
218
+ destino = cities.resuelve(args[2], usuario)
219
+ cambiado = desconecta_local(origen, destino) if destino else desconecta(origen, args[2])
220
+ print(" Road closed." if cambiado else " No such road.")
221
+ return 0
222
+ except (OSError, ValueError, json.JSONDecodeError) as e:
223
+ print(f" {e}", file=sys.stderr)
224
+ return 1
225
+ print(_ayuda(), file=sys.stderr)
226
+ return 1
227
+
228
+
229
+ if __name__ == "__main__":
230
+ sys.exit(main())