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,194 @@
1
+ #!/usr/bin/env python3
2
+ """Report token spend to the city, from your own agent transcripts.
3
+
4
+ Claude Code records the usage of every message it makes — input, output, and both
5
+ kinds of cache — in the session transcripts under ~/.claude/projects. This walks
6
+ them, adds them up per day and per model, and reports the totals.
7
+
8
+ ./bin/tokens.py show what it would send
9
+ ./bin/tokens.py --push send it
10
+ ./bin/tokens.py --days 7 only the last 7 days (default 30)
11
+
12
+ What leaves your machine: numbers. A day, a model name, and four counts. Never a
13
+ prompt, never a file name, never a project path.
14
+
15
+ And what the city does with it: a **global** total. Spend is stored per person
16
+ because that is the only way to deduplicate reports, but there is no endpoint
17
+ that ranks people by it and there is not going to be one. A leaderboard changes
18
+ what people do, and what this map is for is the opposite.
19
+
20
+ Incremental, and carefully so: it remembers the size and mtime of every
21
+ transcript *and the totals it got from it*, so a second run re-reads only what
22
+ changed and still reports the complete picture. Caching only the marks would be
23
+ worse than not caching at all — reports are absolute per day, so a run that
24
+ skipped the other files touched today would overwrite today with a fraction of
25
+ it.
26
+ """
27
+ import argparse
28
+ import json
29
+ import os
30
+ import sys
31
+ import urllib.request
32
+ from collections import defaultdict
33
+ from datetime import datetime, timedelta, timezone
34
+
35
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
36
+ import city_env
37
+
38
+ PROYECTOS = os.path.expanduser('~/.claude/projects')
39
+ ESTADO = os.path.join(city_env.CANAL, 'tokens-state.json')
40
+
41
+
42
+ def estado_previo():
43
+ """{path: {"m": "<size>:<mtime>", "r": {"<day>|<model>": [in, out, cr, cw]}}}"""
44
+ try:
45
+ with open(ESTADO) as f:
46
+ d = json.load(f)
47
+ return d if isinstance(d, dict) else {}
48
+ except Exception:
49
+ return {}
50
+
51
+
52
+ def guarda_estado(d):
53
+ os.makedirs(os.path.dirname(ESTADO), exist_ok=True)
54
+ with open(ESTADO, 'w') as f:
55
+ json.dump(d, f)
56
+
57
+
58
+ def transcripciones(desde_ts):
59
+ for raiz, _, ficheros in os.walk(PROYECTOS):
60
+ for f in ficheros:
61
+ if not f.endswith('.jsonl'):
62
+ continue
63
+ p = os.path.join(raiz, f)
64
+ try:
65
+ st = os.stat(p)
66
+ except OSError:
67
+ continue
68
+ if st.st_mtime < desde_ts:
69
+ continue
70
+ yield p, st
71
+
72
+
73
+ def suma(fichero):
74
+ """Add up one transcript, returning {"<day>|<model>": [in, out, cr, cw]}.
75
+
76
+ Tolerant on purpose: a half-written line at the end of a live session is
77
+ normal, not an error worth stopping for.
78
+ """
79
+ dias = defaultdict(lambda: {'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0})
80
+ try:
81
+ fh = open(fichero, errors='replace')
82
+ except OSError:
83
+ return {}
84
+ with fh:
85
+ for linea in fh:
86
+ if '"usage"' not in linea:
87
+ continue
88
+ try:
89
+ d = json.loads(linea)
90
+ except Exception:
91
+ continue
92
+ m = d.get('message') or {}
93
+ u = m.get('usage') or d.get('usage')
94
+ ts = d.get('timestamp') or ''
95
+ if not u or len(ts) < 10:
96
+ continue
97
+ clave = ts[:10] + '|' + (m.get('model') or d.get('model') or '')[:40]
98
+ a = dias[clave]
99
+ a['input'] += u.get('input_tokens', 0) or 0
100
+ a['output'] += u.get('output_tokens', 0) or 0
101
+ a['cache_read'] += u.get('cache_read_input_tokens', 0) or 0
102
+ a['cache_write'] += u.get('cache_creation_input_tokens', 0) or 0
103
+ return {k: [v['input'], v['output'], v['cache_read'], v['cache_write']]
104
+ for k, v in dias.items()}
105
+
106
+
107
+ def acumula(previo, corte):
108
+ """Every transcript in the window, summed — the cached rows included, because
109
+ the report is the whole window every time (the city stores it absolutely)."""
110
+ nuevo = {}
111
+ total = defaultdict(lambda: [0, 0, 0, 0])
112
+ leidos = saltados = 0
113
+ for p, st in transcripciones(corte.timestamp()):
114
+ marca = f'{st.st_size}:{int(st.st_mtime)}'
115
+ antes = previo.get(p)
116
+ if isinstance(antes, dict) and antes.get('m') == marca:
117
+ filas_f = antes.get('r') or {}
118
+ saltados += 1
119
+ else:
120
+ filas_f = suma(p)
121
+ leidos += 1
122
+ nuevo[p] = {'m': marca, 'r': filas_f}
123
+ for k, v in filas_f.items():
124
+ t = total[k]
125
+ for i in range(4):
126
+ t[i] += v[i]
127
+ return nuevo, total, leidos, saltados
128
+
129
+
130
+ def main():
131
+ ap = argparse.ArgumentParser()
132
+ ap.add_argument('--url', default=city_env.url())
133
+ ap.add_argument('--token', default='')
134
+ ap.add_argument('--days', type=int, default=30)
135
+ ap.add_argument('--push', action='store_true')
136
+ ap.add_argument('--all', action='store_true', help='re-read every transcript, ignoring state')
137
+ ap.add_argument('--quiet', action='store_true', help='say nothing unless it fails')
138
+ a = ap.parse_args()
139
+
140
+ di = (lambda *a, **k: None) if a.quiet else print
141
+ if not os.path.isdir(PROYECTOS):
142
+ di(f'No transcripts at {PROYECTOS}. Nothing to report.')
143
+ return 0
144
+
145
+ corte = datetime.now(timezone.utc) - timedelta(days=a.days)
146
+ desde = corte.strftime('%Y-%m-%d')
147
+ previo = {} if a.all else estado_previo()
148
+ nuevo, total, leidos, saltados = acumula(previo, corte)
149
+
150
+ filas = [{'day': k.split('|', 1)[0], 'model': k.split('|', 1)[1],
151
+ 'input': v[0], 'output': v[1], 'cache_read': v[2], 'cache_write': v[3]}
152
+ for k, v in sorted(total.items()) if k.split('|', 1)[0] >= desde]
153
+
154
+ if not filas:
155
+ di(f'Nothing new in the last {a.days} days. ({saltados} transcripts unchanged.)')
156
+ return 0
157
+
158
+ por_dia = defaultdict(int)
159
+ for f in filas:
160
+ por_dia[f['day']] += f['input'] + f['output']
161
+ di(f' {leidos} transcripts read, {saltados} unchanged\n')
162
+ for d in sorted(por_dia)[-10:]:
163
+ di(f' {d} {por_dia[d]:>12,} tokens')
164
+ total = sum(por_dia.values())
165
+ di(f'\n {total:,} tokens across {len(por_dia)} days, {len(filas)} day/model rows')
166
+
167
+ if not a.push:
168
+ guarda_estado(nuevo) # the reading is the expensive part; keep it either way
169
+ di('\n Nothing sent. Add --push when it looks right.')
170
+ return 0
171
+ if not a.url:
172
+ print('\n No city URL. Pass --url or set AGENTS_CITY_URL.', file=sys.stderr)
173
+ return 1
174
+ tk = city_env.token(a.token)
175
+ if not tk:
176
+ print('\n No bus token. Pass --token or set CITY_BUS_TOKEN.', file=sys.stderr)
177
+ return 1
178
+
179
+ req = urllib.request.Request(
180
+ a.url.rstrip('/') + '/api/tokens',
181
+ data=json.dumps({'days': filas}).encode(),
182
+ headers={'content-type': 'application/json', 'authorization': f'Bearer {tk}'})
183
+ try:
184
+ with urllib.request.urlopen(req, timeout=60) as r:
185
+ di('\n ' + r.read().decode())
186
+ guarda_estado(nuevo)
187
+ except Exception as e:
188
+ print(f'\n The city refused it: {e}', file=sys.stderr)
189
+ return 1
190
+ return 0
191
+
192
+
193
+ if __name__ == '__main__':
194
+ sys.exit(main())
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Mark the folders this session is about to open as already trusted.
3
+
4
+ trust-repos.py <path> [<path>...]
5
+
6
+ Without it every tmux window sits on the "do you trust this folder?" dialog and
7
+ you answer them one at a time.
8
+
9
+ It touches only the `hasTrustDialogAccepted` key of the paths it is given — the
10
+ ones from this person's own card — and nothing else in ~/.claude.json.
11
+ """
12
+ import json
13
+ import os
14
+ import sys
15
+ import tempfile
16
+
17
+ CONF = os.path.expanduser("~/.claude.json")
18
+
19
+
20
+ def main():
21
+ rutas = [os.path.realpath(r) for r in sys.argv[1:] if r]
22
+ if not rutas:
23
+ return
24
+
25
+ try:
26
+ with open(CONF, encoding="utf-8") as f:
27
+ d = json.load(f)
28
+ except FileNotFoundError:
29
+ d = {}
30
+ except (ValueError, OSError) as e:
31
+ print(f"[city] cannot read {CONF}: {e}", file=sys.stderr)
32
+ return
33
+
34
+ proyectos = d.setdefault("projects", {})
35
+ nuevas = 0
36
+ for r in rutas:
37
+ p = proyectos.setdefault(r, {})
38
+ if not p.get("hasTrustDialogAccepted"):
39
+ p["hasTrustDialogAccepted"] = True
40
+ nuevas += 1
41
+
42
+ if nuevas == 0:
43
+ return
44
+
45
+ # Written atomically: this file is big, and losing it hurts.
46
+ carpeta = os.path.dirname(CONF)
47
+ fd, tmp = tempfile.mkstemp(dir=carpeta, prefix=".claude.json.")
48
+ try:
49
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
50
+ json.dump(d, f, indent=2, ensure_ascii=False)
51
+ os.replace(tmp, CONF)
52
+ print(f"[city] trusted {nuevas} folder(s)", file=sys.stderr)
53
+ except OSError as e:
54
+ if os.path.exists(tmp):
55
+ os.unlink(tmp)
56
+ print(f"[city] could not save {CONF}: {e}", file=sys.stderr)
57
+
58
+
59
+ main()
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """Terminal widgets for the setup wizard. Standard library only, no deps.
3
+
4
+ Everything here degrades: if there is no TTY — a pipe, CI, an agent running the
5
+ command — each widget falls back to plain prompts or to its default. A wizard
6
+ that only works in a nice terminal is a wizard that breaks in half the places
7
+ people actually run things.
8
+ """
9
+ import curses
10
+ import os
11
+ import sys
12
+ import textwrap
13
+
14
+ TTY = sys.stdin.isatty() and sys.stdout.isatty()
15
+
16
+ # Scripted answers, for tests and for CI. A wizard that can only be exercised by
17
+ # hand is a wizard whose logic nobody checks, and the logic is where the bugs are:
18
+ # every widget takes its answer from here when the file is present.
19
+ _GUION = None
20
+ if os.environ.get('AGENTS_CITY_ANSWERS'):
21
+ import json as _json
22
+ with open(os.environ['AGENTS_CITY_ANSWERS']) as _f:
23
+ _GUION = _json.load(_f)
24
+ TTY = False
25
+
26
+
27
+ def _guionizado(clave, defecto=None):
28
+ """The scripted answer for this widget, by title.
29
+
30
+ A plain value is *the* answer, used however many times the widget asks — which
31
+ matters because a list is the answer for a multi-select, not a queue of
32
+ answers. For the widgets asked repeatedly in a loop ("another unit", "another
33
+ username") wrap it: {"queue": ["a", "b", ""]}.
34
+ """
35
+ if _GUION is None:
36
+ return ('sin guion', None)
37
+ if clave not in _GUION:
38
+ return ('falta', defecto)
39
+ v = _GUION[clave]
40
+ if isinstance(v, dict) and 'queue' in v:
41
+ cola = v['queue']
42
+ return ('ok', cola.pop(0)) if cola else ('agotado', defecto)
43
+ return ('ok', v)
44
+
45
+ # ── colours ────────────────────────────────────────────────────────────────
46
+ INK, DIM, ACCENT, GOOD, WARN = 1, 2, 3, 4, 5
47
+
48
+
49
+ def _pairs():
50
+ curses.start_color()
51
+ curses.use_default_colors()
52
+ curses.init_pair(INK, curses.COLOR_WHITE, -1)
53
+ curses.init_pair(DIM, curses.COLOR_BLUE, -1)
54
+ curses.init_pair(ACCENT, curses.COLOR_YELLOW, -1)
55
+ curses.init_pair(GOOD, curses.COLOR_GREEN, -1)
56
+ curses.init_pair(WARN, curses.COLOR_RED, -1)
57
+
58
+
59
+ def _chrome(win, title, step, of, hint):
60
+ """Same frame on every screen: where you are, and which keys work here."""
61
+ h, w = win.getmaxyx()
62
+ win.attron(curses.color_pair(ACCENT) | curses.A_BOLD)
63
+ win.addnstr(0, 2, "AGENTS CITY", w - 4)
64
+ win.attroff(curses.color_pair(ACCENT) | curses.A_BOLD)
65
+ if step:
66
+ win.attron(curses.color_pair(DIM))
67
+ win.addnstr(0, 15, f"step {step} of {of}", w - 17)
68
+ win.attroff(curses.color_pair(DIM))
69
+ win.attron(curses.A_BOLD)
70
+ win.addnstr(2, 2, title[: w - 4], w - 4)
71
+ win.attroff(curses.A_BOLD)
72
+ win.attron(curses.color_pair(DIM))
73
+ win.addnstr(h - 1, 2, hint[: w - 4], w - 4)
74
+ win.attroff(curses.color_pair(DIM))
75
+
76
+
77
+ def texto(cuerpo, w):
78
+ """Wrap for the terminal, keeping blank lines as paragraph breaks."""
79
+ out = []
80
+ for p in cuerpo.split("\n\n"):
81
+ out += textwrap.wrap(" ".join(p.split()), max(20, w - 6)) or [""]
82
+ out.append("")
83
+ return out[:-1] if out else out
84
+
85
+
86
+ def pantalla(titulo, cuerpo, step=None, of=None, hint="enter to continue · q to quit"):
87
+ """A page of text. Returns False if the person quits."""
88
+ if not TTY:
89
+ estado, v = _guionizado(titulo, True)
90
+ print(f"[{titulo}] {'skipped' if v is False else 'read'}")
91
+ return True if v is None else bool(v)
92
+
93
+ def run(win):
94
+ curses.curs_set(0)
95
+ _pairs()
96
+ while True:
97
+ win.erase()
98
+ h, w = win.getmaxyx()
99
+ _chrome(win, titulo, step, of, hint)
100
+ for i, l in enumerate(texto(cuerpo, w)[: h - 7]):
101
+ win.addnstr(4 + i, 2, l, w - 4)
102
+ win.refresh()
103
+ k = win.getch()
104
+ if k in (10, 13, curses.KEY_ENTER, ord(" ")):
105
+ return True
106
+ if k in (ord("q"), 27):
107
+ return False
108
+
109
+ return curses.wrapper(run)
110
+
111
+
112
+ def elige(titulo, opciones, marcadas=None, step=None, of=None, minimo=0, ayuda=""):
113
+ """Multi-select with checkboxes. `opciones` is [(id, label, detail)].
114
+
115
+ Returns the list of chosen ids, or None if the person quits.
116
+ """
117
+ marcadas = set(marcadas or [])
118
+ if not TTY:
119
+ estado, v = _guionizado(titulo)
120
+ elegidas = sorted(marcadas) if v is None else v
121
+ print(f"[{titulo}] {elegidas}")
122
+ return elegidas
123
+
124
+ def run(win):
125
+ curses.curs_set(0)
126
+ _pairs()
127
+ sel, top, filtro, escribiendo = 0, 0, "", False
128
+ while True:
129
+ win.erase()
130
+ h, w = win.getmaxyx()
131
+ visibles = [o for o in opciones
132
+ if not filtro or filtro.lower() in (o[0] + " " + o[1]).lower()]
133
+ sel = max(0, min(sel, len(visibles) - 1))
134
+ filas = h - 8
135
+ if sel < top:
136
+ top = sel
137
+ if sel >= top + filas:
138
+ top = sel - filas + 1
139
+
140
+ hint = ("type to filter · esc clear" if escribiendo else
141
+ "↑↓ move · space pick · / filter · a all · n none · enter done · q quit")
142
+ _chrome(win, titulo, step, of, hint)
143
+ if ayuda:
144
+ win.attron(curses.color_pair(DIM))
145
+ win.addnstr(3, 2, ayuda[: w - 4], w - 4)
146
+ win.attroff(curses.color_pair(DIM))
147
+
148
+ for i, (oid, etiqueta, detalle) in enumerate(visibles[top:top + filas]):
149
+ y = 5 + i
150
+ puesto = top + i == sel
151
+ marca = "[x]" if oid in marcadas else "[ ]"
152
+ if puesto:
153
+ win.attron(curses.A_REVERSE)
154
+ win.addnstr(y, 2, f" {marca} {etiqueta} ", w - 4)
155
+ if puesto:
156
+ win.attroff(curses.A_REVERSE)
157
+ if detalle:
158
+ x = 2 + len(f" {marca} {etiqueta} ")
159
+ if x < w - 6:
160
+ win.attron(curses.color_pair(DIM))
161
+ win.addnstr(y, x, detalle[: w - x - 3], w - x - 3)
162
+ win.attroff(curses.color_pair(DIM))
163
+
164
+ pie = f"{len(marcadas)} picked"
165
+ if filtro:
166
+ pie += f" · filter: {filtro}"
167
+ if minimo and len(marcadas) < minimo:
168
+ pie += f" · pick at least {minimo}"
169
+ win.attron(curses.color_pair(ACCENT))
170
+ win.addnstr(h - 3, 2, pie[: w - 4], w - 4)
171
+ win.attroff(curses.color_pair(ACCENT))
172
+ win.refresh()
173
+
174
+ k = win.getch()
175
+ if escribiendo:
176
+ if k in (10, 13, 27):
177
+ escribiendo = False
178
+ elif k in (curses.KEY_BACKSPACE, 127, 8):
179
+ filtro = filtro[:-1]
180
+ elif 32 <= k < 127:
181
+ filtro += chr(k)
182
+ continue
183
+ if k in (curses.KEY_UP, ord("k")):
184
+ sel -= 1
185
+ elif k in (curses.KEY_DOWN, ord("j")):
186
+ sel += 1
187
+ elif k == ord(" ") and visibles:
188
+ oid = visibles[sel][0]
189
+ marcadas.symmetric_difference_update({oid})
190
+ elif k == ord("/"):
191
+ escribiendo, filtro = True, ""
192
+ elif k == ord("a"):
193
+ marcadas.update(o[0] for o in visibles)
194
+ elif k == ord("n"):
195
+ marcadas.difference_update(o[0] for o in visibles)
196
+ elif k in (10, 13, curses.KEY_ENTER):
197
+ if len(marcadas) >= minimo:
198
+ return sorted(marcadas)
199
+ elif k in (ord("q"), 27):
200
+ return None
201
+
202
+ return curses.wrapper(run)
203
+
204
+
205
+ def una(titulo, opciones, step=None, of=None, ayuda=""):
206
+ """Single choice. Returns the id, or None.
207
+
208
+ Unlike `elige`, the cursor wraps: with one thing to pick, running off the end
209
+ and coming back round is what you want. Which also means an empty list would
210
+ divide by zero, so nothing to choose returns nothing rather than a traceback.
211
+ """
212
+ if not opciones:
213
+ return None
214
+ if not TTY:
215
+ estado, v = _guionizado(titulo)
216
+ elegida = (v if v is not None else (opciones[0][0] if opciones else None))
217
+ print(f"[{titulo}] {elegida}")
218
+ return elegida
219
+
220
+ def run(win):
221
+ curses.curs_set(0)
222
+ _pairs()
223
+ sel = 0
224
+ while True:
225
+ win.erase()
226
+ h, w = win.getmaxyx()
227
+ _chrome(win, titulo, step, of, "↑↓ move · enter pick · q quit")
228
+ if ayuda:
229
+ win.attron(curses.color_pair(DIM))
230
+ for i, l in enumerate(texto(ayuda, w)[:3]):
231
+ win.addnstr(3 + i, 2, l, w - 4)
232
+ win.attroff(curses.color_pair(DIM))
233
+ for i, (_oid, etiqueta, detalle) in enumerate(opciones[: h - 9]):
234
+ y = 7 + i
235
+ if i == sel:
236
+ win.attron(curses.A_REVERSE)
237
+ win.addnstr(y, 2, f" {etiqueta} ", w - 4)
238
+ if i == sel:
239
+ win.attroff(curses.A_REVERSE)
240
+ if detalle:
241
+ x = 4 + len(etiqueta)
242
+ if x < w - 6:
243
+ win.attron(curses.color_pair(DIM))
244
+ win.addnstr(y, x, detalle[: w - x - 3], w - x - 3)
245
+ win.attroff(curses.color_pair(DIM))
246
+ win.refresh()
247
+ k = win.getch()
248
+ if k in (curses.KEY_UP, ord("k")):
249
+ sel = (sel - 1) % len(opciones)
250
+ elif k in (curses.KEY_DOWN, ord("j")):
251
+ sel = (sel + 1) % len(opciones)
252
+ elif k in (10, 13, curses.KEY_ENTER):
253
+ return opciones[sel][0]
254
+ elif k in (ord("q"), 27):
255
+ return None
256
+
257
+ return curses.wrapper(run)
258
+
259
+
260
+ def pide(pregunta, defecto="", validar=None, ayuda=""):
261
+ """One line of text, with a default and optional validation."""
262
+ if not TTY:
263
+ estado, v = _guionizado(pregunta, defecto)
264
+ print(f"[{pregunta}] {v!r}")
265
+ return v
266
+ while True:
267
+ curses.endwin() if curses.has_colors() and False else None
268
+ suf = f" [{defecto}]" if defecto else ""
269
+ if ayuda:
270
+ print(f"\n {ayuda}")
271
+ try:
272
+ v = input(f" {pregunta}{suf}: ").strip() or defecto
273
+ except (EOFError, KeyboardInterrupt):
274
+ return None
275
+ if validar:
276
+ problema = validar(v)
277
+ if problema:
278
+ print(f" ↳ {problema}")
279
+ continue
280
+ return v
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """The one reader and writer of `units.yml` — the districts of a city.
3
+
4
+ Three places touched this file each in their own way: the seeder parsed it, the
5
+ wizard emitted it, and the seat emitted a one-district version of it. Three copies
6
+ of one format is how `parcels.yml` ended up with a reader that looked for a key no
7
+ writer wrote, so the districts get the same treatment before they get the chance.
8
+
9
+ The two special districts are part of the format, not of any caller: every city has
10
+ a `lab` (research that does not ship yet, and is needed) and a `none` (what serves
11
+ several units, or none). Writers emit them; readers hand them back like any other.
12
+ """
13
+ import re
14
+ import parcels # the field syntax (`key: value ; key: value`) is the same one
15
+
16
+
17
+ def lee(ruta):
18
+ """Every district in the file, `special` ones included, in file order.
19
+
20
+ Returns [{'id', 'name', 'color', 'order', 'note'}]. Missing file reads as
21
+ empty — the caller decides whether an empty city is an error.
22
+ """
23
+ try:
24
+ with open(ruta, encoding='utf-8') as f:
25
+ txt = f.read()
26
+ except (OSError, IsADirectoryError):
27
+ return []
28
+ fuera = []
29
+ for linea in txt.splitlines():
30
+ if linea.strip().startswith('- id:'):
31
+ c = parcels.campos(linea)
32
+ ident = c.get('id', '')
33
+ try:
34
+ orden = int(c.get('order', 50))
35
+ except ValueError:
36
+ orden = 50
37
+ fuera.append({'id': ident, 'name': c.get('name', ident),
38
+ 'color': c.get('color', 'c8b48a'), 'order': orden,
39
+ 'note': None})
40
+ elif 'note:' in linea and fuera:
41
+ fuera[-1]['note'] = linea.split('note:')[1].strip().strip('"')
42
+ return fuera
43
+
44
+
45
+ def propias(ruta):
46
+ """Only the districts the team declared — lab and none filtered out."""
47
+ return [u for u in lee(ruta) if u['id'] not in ('lab', 'none')]
48
+
49
+
50
+ def escribe(ruta, unidades):
51
+ """Write the whole file: the team's districts plus the two special ones.
52
+
53
+ `unidades` is [{'id', 'name', 'color'}] in display order; `order` is assigned
54
+ from position because that is all it ever meant. Ids are cleaned the same way
55
+ the wizard cleans them, so a caller cannot write an id the map cannot use.
56
+ """
57
+ lineas = ['# Business units — the districts of your city.',
58
+ '#',
59
+ '# The map reads this: names, colours and order come from here, not from the',
60
+ '# code. Change a name and the map redraws itself.',
61
+ 'units:']
62
+ for i, u in enumerate(unidades, 1):
63
+ ident = re.sub(r'[^a-z0-9]+', '-', str(u['id']).lower()).strip('-')[:20] or f'u{i}'
64
+ color = str(u.get('color', 'c8b48a')).lstrip('#').lower()
65
+ if not re.fullmatch(r'[0-9a-f]{6}', color):
66
+ color = 'c8b48a'
67
+ nombre = str(u.get('name', ident)).replace(';', ',')[:20]
68
+ lineas.append(f' - id: {ident:<12} ; name: {nombre:<20} ; color: {color} ; order: {i}')
69
+ lineas += ['',
70
+ '# These two every city has, whatever the business. The lab does not ship yet',
71
+ '# and is needed; "none" is what serves several units, or none. Its houses',
72
+ '# are drawn with permanent scaffolding, and its growth is not compared',
73
+ '# against the shipping units: few floors there is early, not behind.',
74
+ 'special:',
75
+ ' - id: lab ; name: Lab ; color: 8fb8c9 ; order: 90',
76
+ ' note: "doesn\'t ship yet, and it\'s needed"',
77
+ ' - id: none ; name: No unit ; color: c8b48a ; order: 99',
78
+ ' note: "serves several units, or none"']
79
+ with open(ruta, 'w', encoding='utf-8') as f:
80
+ f.write('\n'.join(lineas) + '\n')