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,98 @@
1
+ #!/usr/bin/env python3
2
+ """Read or follow one city's durable activity and diagnostic logs."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+
9
+ import city_env
10
+ import runtime_processes
11
+
12
+
13
+ def read(path):
14
+ try:
15
+ with open(path, encoding='utf-8') as stream:
16
+ return [json.loads(line) for line in stream if line.strip()]
17
+ except (OSError, ValueError, json.JSONDecodeError):
18
+ return []
19
+
20
+
21
+ def display(record, raw=False):
22
+ if raw:
23
+ print(json.dumps(record, ensure_ascii=False), flush=True)
24
+ return
25
+ at = str(record.get('at', ''))[11:19] or '--:--:--'
26
+ source = 'activity' if record.get('protocol') == 'agents-city-activity/1' else 'diagnostic'
27
+ actor = record.get('actor') or record.get('component') or 'city'
28
+ event = record.get('kind') or record.get('event') or 'event'
29
+ message = record.get('summary') or record.get('message') or record.get('outcome') or ''
30
+ print(f'{at} {source:<10} {str(actor):<18} {event} {message}', flush=True)
31
+
32
+
33
+ def main(): # noqa: C901 - CLI selection and follow mode share one stream lifecycle
34
+ parser = argparse.ArgumentParser(
35
+ description='show persistent city conversations and operational diagnostics')
36
+ group = parser.add_mutually_exclusive_group()
37
+ group.add_argument(
38
+ '--activity', action='store_true', help='only visible conversation/work events')
39
+ group.add_argument(
40
+ '--diagnostics', action='store_true', help='only operational diagnostics')
41
+ parser.add_argument('-f', '--follow', action='store_true', help='keep following new events')
42
+ parser.add_argument(
43
+ '-n', '--lines', type=int, default=100, help='initial records (default: 100)')
44
+ parser.add_argument('--json', action='store_true', help='emit JSONL unchanged')
45
+ args = parser.parse_args()
46
+
47
+ data = city_env.datos()
48
+ if not data:
49
+ print('agents-city: no selected city', file=sys.stderr)
50
+ return 2
51
+ directory = runtime_processes.ruta(data)
52
+ selected = []
53
+ if not args.diagnostics:
54
+ selected.append(os.path.join(directory, 'activity.jsonl'))
55
+ if not args.activity:
56
+ selected.append(os.path.join(directory, 'diagnostics.jsonl'))
57
+
58
+ rows = []
59
+ positions = {}
60
+ for path in selected:
61
+ values = read(path)
62
+ rows.extend(values)
63
+ try:
64
+ positions[path] = os.path.getsize(path)
65
+ except OSError:
66
+ positions[path] = 0
67
+ rows.sort(key=lambda row: str(row.get('at', '')))
68
+ for row in rows[-max(0, args.lines):]:
69
+ display(row, args.json)
70
+ if not args.follow:
71
+ return 0
72
+
73
+ try:
74
+ while True:
75
+ for path in selected:
76
+ try:
77
+ size = os.path.getsize(path)
78
+ if size < positions[path]:
79
+ positions[path] = 0
80
+ if size == positions[path]:
81
+ continue
82
+ with open(path, encoding='utf-8') as stream:
83
+ stream.seek(positions[path])
84
+ for line in stream:
85
+ try:
86
+ display(json.loads(line), args.json)
87
+ except json.JSONDecodeError:
88
+ pass
89
+ positions[path] = stream.tell()
90
+ except OSError:
91
+ continue
92
+ time.sleep(.2)
93
+ except KeyboardInterrupt:
94
+ return 0
95
+
96
+
97
+ if __name__ == '__main__':
98
+ sys.exit(main())
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """Road pairing: how an unknown city earns the right to write to a seat.
3
+
4
+ Exchanging invitation files works, but it is friction, and friction is why
5
+ nobody connects two cities. Pairing is the low-friction path: an unknown seat's
6
+ first message yields a short code the owner approves once. Approving grants one
7
+ thing only — permission for that address to send to this seat — never road
8
+ membership, never chair authority, never anything the code's holder did not
9
+ already have.
10
+
11
+ The safety comes from four constants and two rules. A code is short and
12
+ unambiguous (no 0/O/1/I) so it can be read aloud; it expires; only a bounded
13
+ number can be pending per city, so a flood cannot mint an unbounded backlog;
14
+ and the human code is never echoed by tooling — approvals happen by an opaque
15
+ id derived from it. The two rules: a pending request is created (and its code
16
+ revealed) at most once per sender per window, and an approval is only ever
17
+ consulted under the pairing policy, never to widen an allowlist.
18
+
19
+ State is one JSON file per city under its runtime dir. No network, no daemon:
20
+ the seat reads and writes it, the tests drive it on any platform.
21
+ """
22
+
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import secrets
27
+ import time
28
+
29
+ CODIGO_LARGO = 8
30
+ CODIGO_ALFABETO = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # no 0/O/1/I, read-aloud safe
31
+ PENDIENTE_TTL_MS = 60 * 60 * 1000 # a code is good for one hour
32
+ PENDIENTE_MAX = 3 # at most this many pending per city
33
+ CONCEDIDO_MAX = 500 # a generous cap on approved senders
34
+
35
+
36
+ def _ahora_ms():
37
+ return int(time.time() * 1000)
38
+
39
+
40
+ def _estado(datos):
41
+ real = os.path.realpath(os.path.expanduser(datos))
42
+ ruta = os.path.join(real, '.runtime')
43
+ os.makedirs(ruta, mode=0o700, exist_ok=True)
44
+ return os.path.join(ruta, 'pairing.json')
45
+
46
+
47
+ def _lee(datos):
48
+ try:
49
+ with open(_estado(datos), encoding='utf-8') as f:
50
+ objeto = json.load(f)
51
+ except (OSError, json.JSONDecodeError):
52
+ objeto = {}
53
+ objeto.setdefault('pending', {})
54
+ objeto.setdefault('granted', {})
55
+ return objeto
56
+
57
+
58
+ def _escribe(datos, objeto):
59
+ ruta = _estado(datos)
60
+ tmp = f'{ruta}.tmp'
61
+ with open(tmp, 'w', encoding='utf-8') as f:
62
+ json.dump(objeto, f, indent=2, sort_keys=True)
63
+ os.chmod(tmp, 0o600)
64
+ os.replace(tmp, ruta)
65
+
66
+
67
+ def _codigo():
68
+ return ''.join(secrets.choice(CODIGO_ALFABETO) for _ in range(CODIGO_LARGO))
69
+
70
+
71
+ def id_solicitud(direccion, codigo):
72
+ """The opaque id a tool uses to approve, so the human code never travels."""
73
+ crudo = f'{direccion}\0{codigo}'.encode()
74
+ return 'pr_' + hashlib.sha256(crudo).hexdigest()[:20]
75
+
76
+
77
+ def _purga(objeto, ahora):
78
+ objeto['pending'] = {
79
+ d: p for d, p in objeto['pending'].items()
80
+ if ahora - int(p.get('ts', 0)) < PENDIENTE_TTL_MS
81
+ }
82
+
83
+
84
+ def concedido(datos, direccion):
85
+ """True when this seat address has already been approved to write here."""
86
+ objeto = _lee(datos)
87
+ return direccion in objeto['granted']
88
+
89
+
90
+ def solicita(datos, direccion):
91
+ """Record a pending pairing for `direccion`, returning the code exactly once.
92
+
93
+ Returns `{"code", "id", "created": True}` the first time within the TTL, and
94
+ `{"id", "created": False}` on any repeat — the code is revealed once, so
95
+ re-messaging cannot re-spam it, and a flood cannot exceed the pending cap.
96
+ """
97
+ objeto = _lee(datos)
98
+ if direccion in objeto['granted']:
99
+ return {'created': False, 'already': True}
100
+ ahora = _ahora_ms()
101
+ _purga(objeto, ahora)
102
+ existente = objeto['pending'].get(direccion)
103
+ if existente:
104
+ return {'created': False, 'id': existente['id']}
105
+ if len(objeto['pending']) >= PENDIENTE_MAX:
106
+ return {'created': False, 'full': True}
107
+ codigo = _codigo()
108
+ entrada = {'code': codigo, 'id': id_solicitud(direccion, codigo), 'ts': ahora}
109
+ objeto['pending'][direccion] = entrada
110
+ _escribe(datos, objeto)
111
+ return {'created': True, 'code': codigo, 'id': entrada['id']}
112
+
113
+
114
+ def _concede(datos, seleccion):
115
+ """Grant one pending request in a single read/write, given a picker.
116
+
117
+ `seleccion(pending) -> direccion|None` is the only thing that differs
118
+ between approving by code and approving by opaque id, so both entry points
119
+ share the read, purge, cap check, grant and write exactly once.
120
+ """
121
+ objeto = _lee(datos)
122
+ ahora = _ahora_ms()
123
+ _purga(objeto, ahora)
124
+ encontrado = seleccion(objeto['pending'])
125
+ if not encontrado:
126
+ _escribe(datos, objeto)
127
+ return None
128
+ if len(objeto['granted']) >= CONCEDIDO_MAX:
129
+ raise ValueError('the pairing grant table is full; revoke unused senders first')
130
+ objeto['granted'][encontrado] = {'ts': ahora}
131
+ del objeto['pending'][encontrado]
132
+ _escribe(datos, objeto)
133
+ return encontrado
134
+
135
+
136
+ def _por_campo(campo, objetivo):
137
+ """A picker matching one pending field in constant time — no timing signal.
138
+
139
+ The target is compared as UTF-8 bytes so a non-ASCII or non-string input
140
+ (e.g. a client-supplied opaque id) yields a clean no-match instead of the
141
+ TypeError `compare_digest` raises on non-ASCII strings.
142
+ """
143
+ objetivo_b = str(objetivo or '').encode('utf-8')
144
+
145
+ def elige(pending):
146
+ hallado = None
147
+ for direccion, p in pending.items():
148
+ if secrets.compare_digest(str(p.get(campo, '')).encode('utf-8'), objetivo_b):
149
+ hallado = direccion
150
+ return hallado
151
+ return elige
152
+
153
+
154
+ def aprueba(datos, codigo):
155
+ """Approve a pending request by its human code. Returns the address or None.
156
+
157
+ A non-string or empty code matches nothing and returns None — the
158
+ documented 'a wrong code approves nothing', never a crash.
159
+ """
160
+ return _concede(datos, _por_campo('code', str(codigo or '').strip().upper()))
161
+
162
+
163
+ def aprueba_por_id(datos, id_):
164
+ """Approve by opaque id (what tooling holds), never echoing the code."""
165
+ return _concede(datos, _por_campo('id', id_))
166
+
167
+
168
+ def revoca(datos, direccion):
169
+ """Withdraw a previously granted sender. Returns True if one was removed."""
170
+ objeto = _lee(datos)
171
+ quitado = objeto['granted'].pop(direccion, None) is not None
172
+ objeto['pending'].pop(direccion, None)
173
+ _escribe(datos, objeto)
174
+ return quitado
175
+
176
+
177
+ def pendientes(datos):
178
+ """Opaque view of pending requests: id + age, never the code."""
179
+ objeto = _lee(datos)
180
+ ahora = _ahora_ms()
181
+ _purga(objeto, ahora)
182
+ return [
183
+ {'address': d, 'id': p['id'], 'ageMinutes': (ahora - int(p['ts'])) // 60000}
184
+ for d, p in sorted(objeto['pending'].items())
185
+ ]
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """The one reader of `parcels.yml`.
3
+
4
+ There were two, and they were not the same reader. Both walked the format with the
5
+ same byte-identical regex, and then they disagreed: the seeder handled the `lab:`
6
+ section and complained about lines it could not parse; the reporter did neither,
7
+ and read the folder out of a key called `path` that this format has never had. So
8
+ the map and the growth reporter had different ideas about the same file, and the
9
+ reporter's idea was wrong in a way that stopped it working at all.
10
+
11
+ Two readers of one format is a bug waiting for someone to edit only one of them.
12
+
13
+ It lives here, in `plugin/scripts/`, and not next to the seeder, because this
14
+ directory is what gets installed on everybody's machine — the plugin ships without
15
+ `city/`, so the plugin cannot import from there. The seeder runs from a clone and
16
+ can import from here, so this is the only direction that works.
17
+
18
+ Deliberately not YAML: the format is a line at a time on purpose, so this runs
19
+ anywhere with no dependency to install.
20
+
21
+ repos:
22
+ main-site:
23
+ - ruta: "content/**/deposits/**" ; unidad: banking ; nombre: "main · deposits"
24
+ - ruta: "" ; unidad: none ; nombre: "main · platform"
25
+ lab: []
26
+ """
27
+ import re
28
+
29
+ # A parcel line. Values may be quoted, fields are separated by `;`, and a missing
30
+ # field is a default rather than an error.
31
+ CAMPO = re.compile(r'(\w+):\s*"?([^";\n]*?)"?\s*(?:;|$)')
32
+ CABECERA = re.compile(r'^[a-zA-Z0-9_.\-]+:\s*$') # `repos:` / `lab:`
33
+ REPO = re.compile(r'^ [a-zA-Z0-9_.\-]+:\s*$') # ` main-site:`
34
+
35
+
36
+ def campos(linea):
37
+ """`- ruta: "x" ; unidad: y ; nombre: "z"` -> {'ruta': 'x', …}"""
38
+ return dict(CAMPO.findall(linea.strip().lstrip('- ')))
39
+
40
+
41
+ def identidad(repo, ruta):
42
+ """A parcel's id, which has to be computed the same way everywhere.
43
+
44
+ The reporter pushes growth keyed on this and the map stores rows keyed on this.
45
+ If the two ever disagree, growth lands on nothing and neither side says so.
46
+ """
47
+ return f'{repo}:{ruta}' if ruta else repo
48
+
49
+
50
+ def lee(ruta_fichero):
51
+ """Walk the file once. Returns (parcelas, lab, raras).
52
+
53
+ parcelas: [{'id', 'repo', 'ruta', 'unidad', 'nombre'}] in file order
54
+ lab: {repo, …} listed under `lab:`
55
+ raras: lines under a repo that are not parcels — the caller decides whether
56
+ to complain, because a silent one is how a repo ends up in the wrong
57
+ district for a reason nobody can find
58
+ """
59
+ try:
60
+ with open(ruta_fichero, encoding='utf-8') as f:
61
+ txt = f.read()
62
+ except (FileNotFoundError, IsADirectoryError):
63
+ return [], set(), []
64
+
65
+ fuera, lab, raras = [], set(), []
66
+ repo, en_lab = None, False
67
+ for linea in txt.splitlines():
68
+ if re.match(r'^lab:\s*$', linea):
69
+ en_lab, repo = True, None
70
+ continue
71
+ if CABECERA.match(linea):
72
+ en_lab = False
73
+ continue
74
+ if REPO.match(linea):
75
+ repo = linea.strip().rstrip(':')
76
+ elif linea.strip().startswith('- ruta:') and repo:
77
+ c = campos(linea)
78
+ r = c.get('ruta', '')
79
+ fuera.append({'id': identidad(repo, r), 'repo': repo, 'ruta': r,
80
+ 'unidad': c.get('unidad', 'none'),
81
+ 'nombre': c.get('nombre', repo)})
82
+ elif linea.strip().startswith('- '):
83
+ suelto = linea.strip().lstrip('- ').strip()
84
+ if en_lab:
85
+ if suelto:
86
+ lab.add(suelto)
87
+ elif repo:
88
+ raras.append(linea.strip()[:70])
89
+ return fuera, lab, raras
90
+
91
+
92
+ def escribe(ruta_fichero, porRepo, lab=()):
93
+ """Write the whole file back, in the shape `lee` reads.
94
+
95
+ `porRepo` is {repo: [{'ruta', 'unidad', 'nombre'}, …]}. This exists so the
96
+ modelling — the one thing no tool can do for you — can be edited with a form
97
+ instead of by hand in a format nobody remembers. Round-tripping through `lee`
98
+ is pinned by a test, because a writer that drifts from its reader is exactly
99
+ the bug this module was created to end.
100
+ """
101
+ lineas = ['# Which slice of which repo serves which unit.',
102
+ '#',
103
+ '# A house is not a repo: it is a **parcel**, a slice of a repo serving one',
104
+ '# unit. One repo can hold several, and that is the central fact of most',
105
+ '# codebases.',
106
+ '',
107
+ 'repos:']
108
+ for repo in sorted(porRepo):
109
+ filas = porRepo[repo] or [{'ruta': '', 'unidad': 'none', 'nombre': repo}]
110
+ lineas.append(f' {repo}:')
111
+ for f in filas:
112
+ ruta = str(f.get('ruta', '')).replace(';', '').replace('"', '')
113
+ unidad = str(f.get('unidad', 'none')).replace(';', '') or 'none'
114
+ nombre = str(f.get('nombre') or repo).replace(';', ',').replace('"', "'")
115
+ lineas.append(f' - ruta: "{ruta}" ; unidad: {unidad} ; nombre: "{nombre}"')
116
+ lineas += ['', '# Research that does not ship yet gets its own district instead of',
117
+ '# reading as waste among the rest.']
118
+ lineas.append('lab:' if lab else 'lab: []')
119
+ for r in sorted(lab):
120
+ lineas.append(f' - {r}')
121
+ with open(ruta_fichero, 'w', encoding='utf-8') as f:
122
+ f.write('\n'.join(lineas) + '\n')
123
+
124
+
125
+ def aviso(raras):
126
+ """What to say about lines that could not be read, or '' if there were none."""
127
+ if not raras:
128
+ return ''
129
+ lineas = [f'parcels.yml: {len(raras)} line(s) I could not read — they are being '
130
+ f'ignored. Use: - ruta: "<glob>" ; unidad: <unit> ; nombre: "<name>"']
131
+ lineas += [f' {r}' for r in raras[:5]]
132
+ return '\n'.join(lineas)
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Read one field out of a card's frontmatter, for the shell scripts.
3
+
4
+ read-card.py <card.md> agent
5
+ read-card.py <card.md> repos -> comma-separated list
6
+ read-card.py --window <repo> -> canonical tmux/bus actor slug
7
+ read-card.py --actor-role <card.md> <actor> -> safe operating role or blank
8
+
9
+ A thin front on card.py, which is where the parsing lives. It used to hold its own
10
+ copy, and that copy is why the tmux session asked for `agente` — a field no card in
11
+ this repo has ever carried — and got nothing every single time.
12
+
13
+ `agente` still answers, for a card somebody wrote by hand.
14
+ """
15
+ import os
16
+ import sys
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+ import card # noqa: E402
20
+
21
+
22
+ def main():
23
+ if len(sys.argv) == 3 and sys.argv[1] == '--window':
24
+ print(card.ventana(sys.argv[2]))
25
+ return
26
+ if len(sys.argv) == 4 and sys.argv[1] == '--actor-role':
27
+ try:
28
+ texto = open(sys.argv[2], encoding='utf-8').read()
29
+ except OSError as e:
30
+ sys.exit(f'cannot read {sys.argv[2]}: {e}')
31
+ actor = card.ventana(sys.argv[3])
32
+ print(card.rol_seguro(card.campo(texto, f'role.{actor}')))
33
+ return
34
+ if len(sys.argv) < 3:
35
+ sys.exit(
36
+ 'usage: read-card.py <card.md> <field> | --window <repo> | '
37
+ '--actor-role <card.md> <actor>'
38
+ )
39
+ ruta, campo = sys.argv[1], sys.argv[2]
40
+ try:
41
+ with open(ruta, encoding='utf-8') as f:
42
+ texto = f.read()
43
+ except OSError as e:
44
+ sys.exit(f'cannot read {ruta}: {e}')
45
+ valor = card.campo(texto, campo)
46
+ # `repos` comes back as a list everywhere else; the shell wants one line.
47
+ print(','.join(card.lista(valor)) if campo == 'repos' else valor)
48
+
49
+
50
+ if __name__ == '__main__':
51
+ main()
@@ -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())