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,702 @@
1
+ /**
2
+ * Agents City — Worker.
3
+ *
4
+ * GET / the front end (assets)
5
+ * GET /api/city what is built: parcels + houses (D1)
6
+ * GET /api/state the now: lights and notices (Durable Object)
7
+ * GET /ws live spectator (WebSocket, receive only)
8
+ * POST /ingest event intake from the bus (X-City-Secret header)
9
+ * cron GitHub -> D1, in batches
10
+ *
11
+ * The split: the DO holds the now, D1 holds the past. Never both the same thing.
12
+ */
13
+ import { Square } from './square';
14
+ export { Square };
15
+
16
+ interface Env {
17
+ ASSETS: Fetcher;
18
+ TOKENS: KVNamespace; // the bus's own KV: identity is never set by the client
19
+ SQUARE: DurableObjectNamespace;
20
+ DB: D1Database;
21
+ GITHUB_ORG: string;
22
+ CITY_ID?: string; // local Hall discovery: never frame another city's map
23
+ CITY_SECRET?: string; // wrangler secret put CITY_SECRET
24
+ GITHUB_TOKEN?: string; // wrangler secret put GITHUB_TOKEN
25
+ REQUIRE_ACCESS?: string; // "1" in production: no Access header, nothing served
26
+ PASSPHRASE?: string; // stopgap door, until the Access app exists
27
+ PASS_SIGNING_KEY?: string; // key the pass cookie is signed with
28
+ }
29
+
30
+ const square = (env: Env) => env.SQUARE.get(env.SQUARE.idFromName('city'));
31
+
32
+ function formulario(error = ''): string {
33
+ return `<!doctype html><meta charset="utf-8"><title>Agents City</title>
34
+ <body style="background:#131a24;color:#e8e2d4;font:16px/1.6 Georgia,serif;display:grid;place-items:center;height:100vh;margin:0">
35
+ <form method="post" action="/entrar" style="display:grid;gap:12px;width:min(320px,80vw)">
36
+ <div style="font:600 22px/1.1 system-ui;letter-spacing:.01em">Agents City</div>
37
+ ${error ? `<div style="color:#e2604f;font-size:14px">${error}</div>` : ''}
38
+ <input name="clave" type="password" autofocus placeholder="passphrase"
39
+ style="padding:10px 12px;background:#1b2431;border:1px solid #38495f;color:#e8e2d4;border-radius:3px;font:14px ui-monospace,monospace">
40
+ <button style="padding:10px;background:#f5c451;border:0;border-radius:3px;font:600 14px system-ui;cursor:pointer">Enter</button>
41
+ <div style="font-size:12px;color:#6b7889">A stopgap door, until Cloudflare Access is in front of this.</div>
42
+ </form></body>`;
43
+ }
44
+
45
+ /**
46
+ * Compare without leaking through timing. A `!==` returns on the first differing
47
+ * byte, and that is enough to guess a secret byte by byte by measuring the
48
+ * response. Here the secret opens the door to writing what the whole team sees.
49
+ */
50
+ function igualSeguro(a: string, b: string): boolean {
51
+ if (a.length !== b.length) return false;
52
+ let d = 0;
53
+ for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
54
+ return d === 0;
55
+ }
56
+
57
+ async function sha256(s: string): Promise<string> {
58
+ const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
59
+ return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join('');
60
+ }
61
+
62
+ /**
63
+ * The public face only opens behind an identity proxy.
64
+ *
65
+ * Access filters at the edge, but a workers.dev URL walks straight past it: if
66
+ * the Worker checks nothing, the map of your whole codebase sits on the internet
67
+ * for anyone who finds the subdomain. With REQUIRE_ACCESS=1, nothing is served
68
+ * without the header the proxy sets.
69
+ *
70
+ * This does not replace Access. It is the second door, for the path Access does
71
+ * not cover.
72
+ */
73
+ async function pasaLaPuerta(req: Request, env: Env): Promise<boolean> {
74
+ if (env.REQUIRE_ACCESS !== '1') return true;
75
+ if (req.headers.get('Cf-Access-Jwt-Assertion')) return true;
76
+ return paseValido(req, env);
77
+ }
78
+
79
+ /* ── Puerta provisional ─────────────────────────────────────────────────────
80
+ * For before the identity proxy exists. It is weaker and worth knowing why: a
81
+ * shared passphrase says somebody knows it, never WHO came in. What it does do
82
+ * right: the passphrase does not travel on every request — it is swapped for a
83
+ * signed, HttpOnly, twelve-hour cookie — the comparison is constant-time, and
84
+ * failed attempts go through the throttle.
85
+ *
86
+ * It disappears on its own: with no PASSPHRASE secret, this door does not exist.
87
+ * Once
88
+ * Access is in place: `wrangler secret delete PASSPHRASE` and it is gone.
89
+ */
90
+ async function firma(valor: string, env: Env): Promise<string> {
91
+ const clave = await crypto.subtle.importKey(
92
+ 'raw',
93
+ new TextEncoder().encode(env.PASS_SIGNING_KEY ?? 'unsigned'),
94
+ { name: 'HMAC', hash: 'SHA-256' },
95
+ false,
96
+ ['sign'],
97
+ );
98
+ const f = await crypto.subtle.sign('HMAC', clave, new TextEncoder().encode(valor));
99
+ return [...new Uint8Array(f)].map((b) => b.toString(16).padStart(2, '0')).join('');
100
+ }
101
+
102
+ async function paseValido(req: Request, env: Env): Promise<boolean> {
103
+ if (!env.PASSPHRASE || !env.PASS_SIGNING_KEY) return false;
104
+ const galleta = (req.headers.get('Cookie') ?? '')
105
+ .split(';')
106
+ .map((c) => c.trim())
107
+ .find((c) => c.startsWith('pase='));
108
+ if (!galleta) return false;
109
+ const [caduca, mac] = galleta.slice(5).split('.');
110
+ if (!caduca || !mac) return false;
111
+ if (Number(caduca) < Date.now()) return false;
112
+ return igualSeguro(mac, await firma(caduca, env));
113
+ }
114
+
115
+ /**
116
+ * Who is reporting: the owner of the token, never what the client claims. Same
117
+ * rule as the bus — identity is looked up, not accepted.
118
+ */
119
+ async function quienEs(req: Request, env: Env): Promise<string | null> {
120
+ const auth = req.headers.get('Authorization') ?? '';
121
+ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
122
+ if (!token) return null;
123
+ const raw = await env.TOKENS.get(`tok:${await sha256(token)}`);
124
+ if (!raw) return null;
125
+ try {
126
+ const { user } = JSON.parse(raw) as { user?: string };
127
+ return user || null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * How often one seat may write. A valid token in a loop — a hook that lost its
135
+ * throttle, a script in a `while true` — would otherwise hold a D1 write open
136
+ * for every iteration. The same per-sender counter the square already keeps, so
137
+ * there is one place where "too often" is defined.
138
+ */
139
+ async function pasaElRitmo(env: Env, quien: string): Promise<boolean> {
140
+ const r = await square(env).fetch(
141
+ new Request('https://square/ingest', {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/json' },
144
+ body: JSON.stringify({ t: 'attempt', usuario: `report:${quien}`, user: `report:${quien}` }),
145
+ }),
146
+ );
147
+ return r.ok;
148
+ }
149
+
150
+ export default {
151
+ async fetch(req: Request, env: Env): Promise<Response> {
152
+ const url = new URL(req.url);
153
+
154
+ /**
155
+ * The endpoints that report INTO the city carry their own authentication —
156
+ * a bus token, or the shared secret — so they do not go through the identity
157
+ * door. Everything else, the web and its read APIs, does.
158
+ *
159
+ * All four of them, and not the two this used to list: with Access in front,
160
+ * a reporter running on somebody's laptop gets the login page instead of the
161
+ * API, and the map silently stops learning about growth and spend. A door
162
+ * that blocks the sensors is not a security measure, it is an outage.
163
+ */
164
+ const ENTRADAS = new Set(['/ingest', '/worker', '/api/growth', '/api/tokens']);
165
+ const esEntrada = ENTRADAS.has(url.pathname) && req.method === 'POST';
166
+
167
+ // The stopgap door: the passphrase is swapped for a signed cookie.
168
+ if (url.pathname === '/entrar') {
169
+ if (!env.PASSPHRASE || !env.PASS_SIGNING_KEY) {
170
+ return new Response('this door is not open', { status: 404 });
171
+ }
172
+ const dada =
173
+ url.searchParams.get('clave') ??
174
+ (req.method === 'POST' ? (await req.formData()).get('clave')?.toString() : null);
175
+ if (!dada)
176
+ return new Response(formulario(), {
177
+ headers: { 'content-type': 'text/html; charset=utf-8' },
178
+ });
179
+
180
+ // Per-IP throttle: without it, a shared passphrase is brute-forced.
181
+ const ip = req.headers.get('CF-Connecting-IP') ?? 'no-ip';
182
+ const fr = await square(env).fetch(
183
+ new Request('https://square/ingest', {
184
+ method: 'POST',
185
+ headers: { 'content-type': 'application/json' },
186
+ body: JSON.stringify({ t: 'attempt', usuario: `pase:${ip}` }),
187
+ }),
188
+ );
189
+ if (!fr.ok) return new Response('too many attempts, wait a minute', { status: 429 });
190
+
191
+ if (!igualSeguro(dada, env.PASSPHRASE)) {
192
+ return new Response(formulario('That is not the passphrase'), {
193
+ status: 401,
194
+ headers: { 'content-type': 'text/html; charset=utf-8' },
195
+ });
196
+ }
197
+ const caduca = String(Date.now() + 12 * 3600_000);
198
+ const pase = `${caduca}.${await firma(caduca, env)}`;
199
+ return new Response(null, {
200
+ status: 302,
201
+ headers: {
202
+ location: '/',
203
+ 'set-cookie': `pase=${pase}; Path=/; Max-Age=43200; HttpOnly; Secure; SameSite=Lax`,
204
+ },
205
+ });
206
+ }
207
+
208
+ if (!esEntrada && !(await pasaLaPuerta(req, env))) {
209
+ const cerrada = 'This view is for your team only.';
210
+ const con = env.PASSPHRASE ? ` Have the passphrase? <a href="/entrar">Sign in</a>.` : '';
211
+ return new Response(
212
+ `<!doctype html><meta charset="utf-8">
213
+ <body style="background:#131a24;color:#e8e2d4;font:16px/1.6 Georgia,serif;padding:14vh 8vw">
214
+ <p>${cerrada}${con}</p></body>`,
215
+ { status: 403, headers: { 'content-type': 'text/html; charset=utf-8' } },
216
+ );
217
+ }
218
+
219
+ if (url.pathname === '/ws' || url.pathname === '/api/state') {
220
+ const destino = url.pathname === '/ws' ? '/ws' : '/state';
221
+ return square(env).fetch(new Request('https://plaza' + destino, req));
222
+ }
223
+
224
+ if (url.pathname === '/ingest') {
225
+ if (req.method !== 'POST') return new Response('no', { status: 405 });
226
+ // The bus authenticates with a shared secret. Without it nothing gets in:
227
+ // this endpoint writes the state everybody looks at.
228
+ const dado = req.headers.get('X-City-Secreto') ?? '';
229
+ if (!env.CITY_SECRET || !igualSeguro(dado, env.CITY_SECRET)) {
230
+ return new Response('not authorised', { status: 401 });
231
+ }
232
+ const ev = await req.json<any>();
233
+ // The DO's response is propagated: if it throttles, the sender has to find
234
+ // out. This used to be discarded, which made the throttle useless.
235
+ const r = await square(env).fetch(
236
+ new Request('https://square/ingest', {
237
+ method: 'POST',
238
+ body: JSON.stringify(ev),
239
+ headers: { 'content-type': 'application/json' },
240
+ }),
241
+ );
242
+ if (!r.ok) return new Response(await r.text(), { status: r.status });
243
+ if (ev.t === 'notice') {
244
+ await env.DB.prepare(
245
+ 'INSERT INTO evento (ts,tipo,origen,destino,etiqueta,texto) VALUES (?,?,?,?,?,?)',
246
+ )
247
+ .bind(new Date().toISOString(), 'carta', ev.from, ev.to, ev.tag ?? null, ev.text ?? null)
248
+ .run();
249
+ }
250
+ return Response.json({ ok: true });
251
+ }
252
+
253
+ // A worker announces itself with ITS OWN bus token, not a shared secret. So
254
+ // the user comes from the token and nobody can dig in somebody else's name —
255
+ // exactly the property the bus already gave us.
256
+ if (url.pathname === '/worker' && req.method === 'POST') {
257
+ const user = await quienEs(req, env);
258
+ if (!user) return new Response('invalid or missing token', { status: 401 });
259
+
260
+ const body = await req.json<any>();
261
+ // The window name is written by a hook on somebody else's machine: it gets
262
+ // trimmed and stripped here, at the door, rather than trusting the front
263
+ // end to escape it. Both, not either.
264
+ const limpio = (v: unknown, n: number) =>
265
+ String(v ?? '')
266
+ .replace(/[<>"'`\\]/g, '')
267
+ .slice(0, n);
268
+ const r = await square(env).fetch(
269
+ new Request('https://square/ingest', {
270
+ method: 'POST',
271
+ headers: { 'content-type': 'application/json' },
272
+ body: JSON.stringify({
273
+ t: 'worker',
274
+ user,
275
+ window: limpio(body.ventana, 40) || 'unnamed',
276
+ parcel: limpio(body.parcela, 200),
277
+ stopped: !!body.parada,
278
+ }),
279
+ }),
280
+ );
281
+ if (!r.ok) return new Response(await r.text(), { status: r.status });
282
+ return Response.json({ ok: true, user });
283
+ }
284
+
285
+ /**
286
+ * The replay. Returns two things, not one:
287
+ * base — the floors each parcel ALREADY had before the start date, so the
288
+ * film does not open on an empty city.
289
+ * dias — what landed on each day inside the range.
290
+ *
291
+ * That way you can ask for "the last 90 days" without dragging five years of
292
+ * history, and the heights are still the real ones. Two aggregate queries,
293
+ * none per parcel and none per day.
294
+ */
295
+ if (url.pathname === '/api/historia') {
296
+ const hoy = new Date().toISOString().slice(0, 10);
297
+ const dia = (v: string | null, pordefecto: string) =>
298
+ /^\d{4}-\d{2}-\d{2}$/.test(v ?? '') ? (v as string) : pordefecto;
299
+ const hasta = dia(url.searchParams.get('hasta'), hoy);
300
+ const desde = dia(
301
+ url.searchParams.get('desde'),
302
+ new Date(Date.parse(hasta) - 89 * 864e5).toISOString().slice(0, 10),
303
+ );
304
+
305
+ const [base, dias, limites, avisos] = await env.DB.batch([
306
+ env.DB.prepare(
307
+ 'SELECT parcela_id, SUM(n) n FROM hito WHERE dia < ? GROUP BY parcela_id',
308
+ ).bind(desde),
309
+ env.DB.prepare(
310
+ 'SELECT dia, parcela_id, n FROM hito WHERE dia >= ? AND dia <= ? ORDER BY dia',
311
+ ).bind(desde, hasta),
312
+ env.DB.prepare('SELECT MIN(dia) primero, MAX(dia) ultimo FROM hito'),
313
+ // The notices of the range, by day: the replay flies them between houses.
314
+ // Without them the film only grows buildings, and nobody watches that
315
+ // twice.
316
+ env.DB.prepare(
317
+ `SELECT substr(ts,1,10) dia, origen, destino, etiqueta, texto
318
+ FROM evento WHERE tipo='notice' AND substr(ts,1,10) >= ?
319
+ AND substr(ts,1,10) <= ? ORDER BY ts LIMIT 4000`,
320
+ ).bind(desde, hasta),
321
+ ]);
322
+
323
+ const porDia = new Map<string, [string, number][]>();
324
+ for (const h of dias.results as any[]) {
325
+ const l = porDia.get(h.dia);
326
+ l ? l.push([h.parcela_id, h.n]) : porDia.set(h.dia, [[h.parcela_id, h.n]]);
327
+ }
328
+ return Response.json(
329
+ {
330
+ desde,
331
+ hasta,
332
+ limites: (limites.results as any[])[0] ?? {},
333
+ base: (base.results as any[]).map((b) => [b.parcela_id, b.n]),
334
+ dias: [...porDia.entries()],
335
+ avisos: (avisos.results as any[]).map((a) => [
336
+ a.dia,
337
+ a.origen,
338
+ a.destino,
339
+ a.etiqueta,
340
+ a.texto,
341
+ ]),
342
+ },
343
+ { headers: { 'cache-control': 'public, max-age=600' } },
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Growth reported from outside, for cities that are not made of code.
349
+ *
350
+ * The cron can count merged pull requests on its own, but it cannot run a
351
+ * command inside somebody's folder — and in a marketing, legal or finance
352
+ * city, growth is whatever a command in that folder returns. So the number
353
+ * comes from a reporter running where the folders are, authenticated with the
354
+ * same bus token as everything else: the user is the token's, never the
355
+ * client's claim.
356
+ *
357
+ * The day's history row is the DELTA, computed here. The reporter sends a
358
+ * total, which is the only thing it can know reliably, and the city works out
359
+ * what changed today — so the replay works the same for every kind of city.
360
+ */
361
+ if (url.pathname === '/api/growth' && req.method === 'POST') {
362
+ const quien = await quienEs(req, env);
363
+ if (!quien) return new Response('invalid or missing token', { status: 401 });
364
+ if (!(await pasaElRitmo(env, quien)))
365
+ return new Response('too many reports', { status: 429 });
366
+
367
+ const body = await req.json<{
368
+ parcels?: {
369
+ id: string;
370
+ floors: number;
371
+ scaffolds?: number;
372
+ stale?: number;
373
+ cracked?: number;
374
+ activity?: number;
375
+ }[];
376
+ }>();
377
+ const lista = (body.parcels ?? []).slice(0, 500);
378
+ if (!lista.length) return Response.json({ ok: true, updated: 0 });
379
+
380
+ const hoy = new Date().toISOString().slice(0, 10);
381
+ const ahora = new Date().toISOString();
382
+ const previos = new Map<string, number>();
383
+ const { results } = await env.DB.prepare(
384
+ `SELECT parcela_id, pisos FROM casa WHERE parcela_id IN (${lista.map(() => '?').join(',')})`,
385
+ )
386
+ .bind(...lista.map((p) => p.id))
387
+ .all<{ parcela_id: string; pisos: number }>();
388
+ for (const r of results) previos.set(r.parcela_id, r.pisos);
389
+
390
+ const casa = env.DB.prepare(`
391
+ INSERT INTO casa (parcela_id,pisos,ladrillos,andamios,andamio_viejo,grieta,actividad30,actualizado)
392
+ VALUES (?,?,0,?,?,?,?,?)
393
+ ON CONFLICT(parcela_id) DO UPDATE SET pisos=excluded.pisos, andamios=excluded.andamios,
394
+ andamio_viejo=excluded.andamio_viejo, grieta=excluded.grieta,
395
+ actividad30=excluded.actividad30, actualizado=excluded.actualizado`);
396
+ const hito = env.DB.prepare(`
397
+ INSERT INTO hito (parcela_id,dia,n) VALUES (?,?,?)
398
+ ON CONFLICT(parcela_id,dia) DO UPDATE SET n = hito.n + excluded.n`);
399
+
400
+ const lotes = [];
401
+ for (const p of lista) {
402
+ const antes = previos.get(p.id) ?? 0;
403
+ const delta = Math.max(0, (p.floors | 0) - antes);
404
+ lotes.push(
405
+ casa.bind(
406
+ p.id,
407
+ p.floors | 0,
408
+ p.scaffolds ?? 0,
409
+ p.stale ?? 0,
410
+ p.cracked ?? 0,
411
+ p.activity ?? 0,
412
+ ahora,
413
+ ),
414
+ );
415
+ if (delta > 0) lotes.push(hito.bind(p.id, hoy, delta));
416
+ }
417
+ await env.DB.batch(lotes);
418
+ return Response.json({ ok: true, updated: lista.length });
419
+ }
420
+
421
+ /**
422
+ * Token spend, reported by each person's machine.
423
+ *
424
+ * The numbers come from the agent's own transcripts, which is the only place
425
+ * they exist — and they are counts, never content. Authenticated with the bus
426
+ * token like everything else, so the user is the token's.
427
+ *
428
+ * Reports are absolute per (day, model), not increments, so re-running the
429
+ * reporter is safe and a machine that was off for a week catches up on its
430
+ * own without double counting.
431
+ */
432
+ if (url.pathname === '/api/tokens' && req.method === 'POST') {
433
+ const user = await quienEs(req, env);
434
+ if (!user) return new Response('invalid or missing token', { status: 401 });
435
+ if (!(await pasaElRitmo(env, user))) return new Response('too many reports', { status: 429 });
436
+
437
+ const body = await req.json<{
438
+ days?: {
439
+ day: string;
440
+ model?: string;
441
+ input?: number;
442
+ output?: number;
443
+ cache_read?: number;
444
+ cache_write?: number;
445
+ }[];
446
+ }>();
447
+ const filas = (body.days ?? [])
448
+ .filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d.day))
449
+ .slice(0, 800);
450
+ if (!filas.length) return Response.json({ ok: true, updated: 0 });
451
+ // Counts come from somebody else's machine, so they get clamped here and
452
+ // not trusted: a string, a NaN or an absurd number would otherwise land in
453
+ // the table and poison a total that everybody reads.
454
+ const n = (v: unknown) => {
455
+ const x = Math.trunc(Number(v));
456
+ return Number.isFinite(x) && x > 0 ? Math.min(x, 1e12) : 0;
457
+ };
458
+
459
+ const stmt = env.DB.prepare(`
460
+ INSERT INTO gasto (dia,usuario,modelo,entrada,salida,cache_r,cache_w)
461
+ VALUES (?,?,?,?,?,?,?)
462
+ ON CONFLICT(dia,usuario,modelo) DO UPDATE SET entrada=excluded.entrada,
463
+ salida=excluded.salida, cache_r=excluded.cache_r, cache_w=excluded.cache_w`);
464
+ await env.DB.batch(
465
+ filas.map((d) =>
466
+ stmt.bind(
467
+ d.day,
468
+ user,
469
+ String(d.model ?? '').slice(0, 40),
470
+ n(d.input),
471
+ n(d.output),
472
+ n(d.cache_read),
473
+ n(d.cache_write),
474
+ ),
475
+ ),
476
+ );
477
+ return Response.json({ ok: true, updated: filas.length, user });
478
+ }
479
+
480
+ if (url.pathname === '/api/tokens' && req.method === 'GET') {
481
+ // Global totals, nothing per person. The HUD polls this instead of
482
+ // re-downloading every parcel just to refresh two numbers.
483
+ const hoy = new Date().toISOString().slice(0, 10);
484
+ const hace30 = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10);
485
+ const [g1, g30] = await env.DB.batch([
486
+ env.DB.prepare(
487
+ `SELECT SUM(entrada+salida) tokens, SUM(cache_r) cache,
488
+ COUNT(DISTINCT usuario) gente FROM gasto WHERE dia = ?`,
489
+ ).bind(hoy),
490
+ env.DB.prepare(
491
+ `SELECT SUM(entrada+salida) tokens, SUM(cache_r) cache
492
+ FROM gasto WHERE dia >= ?`,
493
+ ).bind(hace30),
494
+ ]);
495
+ return Response.json(
496
+ { hoy: (g1.results as any[])[0] ?? {}, mes: (g30.results as any[])[0] ?? {} },
497
+ { headers: { 'cache-control': 'public, max-age=60' } },
498
+ );
499
+ }
500
+
501
+ if (url.pathname === '/api/identity') {
502
+ return Response.json({ cityId: env.CITY_ID || '' });
503
+ }
504
+
505
+ if (url.pathname === '/api/city') {
506
+ // The per-repo count rides along as a window function, so the front end can
507
+ // say when a figure belongs to the whole repo rather than to this parcel —
508
+ // without asking for anything else. One query, one pass: the correlated
509
+ // subquery this used to be re-scanned `parcela` once per row.
510
+ const { results } = await env.DB.prepare(
511
+ `
512
+ SELECT p.id, p.repo, p.ruta, p.unidad, p.nombre, p.dueno,
513
+ COUNT(*) OVER (PARTITION BY p.repo) hermanas,
514
+ COALESCE(c.pisos,0) pisos, COALESCE(c.ladrillos,0) ladrillos,
515
+ COALESCE(c.andamios,0) andamios, COALESCE(c.andamio_viejo,0) andamio_viejo,
516
+ COALESCE(c.grieta,0) grieta, COALESCE(c.actividad30,0) actividad30
517
+ FROM parcela p LEFT JOIN casa c ON c.parcela_id = p.id
518
+ ORDER BY p.unidad, p.repo, p.ruta`,
519
+ ).all();
520
+ // Units come from the database, not from the code: every organisation has
521
+ // its own, and changing them should not need a deploy.
522
+ const hoy = new Date().toISOString().slice(0, 10);
523
+ const hace30 = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10);
524
+ const [personas, unidades, gastoHoy, gasto30, metas] = await env.DB.batch([
525
+ env.DB.prepare('SELECT usuario, nombre, rol, oficio, agente FROM persona'),
526
+ env.DB.prepare('SELECT id, nombre, color, orden, nota, cols FROM unidad ORDER BY orden'),
527
+ // Global totals only. There is no endpoint that ranks people by spend,
528
+ // and adding one would break the thing this map is for.
529
+ env.DB.prepare(
530
+ `SELECT SUM(entrada+salida) tokens, SUM(cache_r) cache,
531
+ COUNT(DISTINCT usuario) gente FROM gasto WHERE dia = ?`,
532
+ ).bind(hoy),
533
+ env.DB.prepare(
534
+ `SELECT SUM(entrada+salida) tokens, SUM(cache_r) cache
535
+ FROM gasto WHERE dia >= ?`,
536
+ ).bind(hace30),
537
+ // The goals ride along: twelve rows for a twelve-person city, and they
538
+ // are the one thing that does not change whatever the city is made of.
539
+ env.DB.prepare(`SELECT usuario, n, titulo, como, medida, partida, meta, cuando, estado
540
+ FROM objetivo ORDER BY usuario, n`),
541
+ ]);
542
+ return Response.json(
543
+ {
544
+ parcelas: results,
545
+ personas: personas.results,
546
+ unidades: unidades.results,
547
+ objetivos: metas.results,
548
+ gasto: {
549
+ hoy: (gastoHoy.results as any[])[0] ?? {},
550
+ mes: (gasto30.results as any[])[0] ?? {},
551
+ },
552
+ },
553
+ {
554
+ headers: { 'cache-control': 'public, max-age=60' },
555
+ },
556
+ );
557
+ }
558
+
559
+ // The page itself is never cached; the bundle it points at is, and its URL
560
+ // carries the bundle's fingerprint. Otherwise a rebuilt map is invisible
561
+ // until somebody thinks to hard-reload, which is a thing nobody thinks to do.
562
+ const r = await env.ASSETS.fetch(req);
563
+ if (url.pathname === '/' || url.pathname.endsWith('.html')) {
564
+ const h = new Headers(r.headers);
565
+ h.set('cache-control', 'no-cache');
566
+ return new Response(r.body, { status: r.status, headers: h });
567
+ }
568
+ return r;
569
+ },
570
+
571
+ /**
572
+ * What actually got built.
573
+ *
574
+ * This used to fetch one page of PRs and one of commits and count what came
575
+ * back: with `per_page=100`, any active repo capped at 100 — which is the same
576
+ * as not measuring at all.
577
+ *
578
+ * Now it uses the search API, which returns an exact `total_count` in a single
579
+ * call per metric. That comes with a tighter limit — 30 queries a minute — so
580
+ * the pass runs in batches: each firing takes a few repos and advances a
581
+ * cursor. A whole organisation refreshes in a couple of hours, which for "how
582
+ * many floors does this house have" is plenty.
583
+ */
584
+ async scheduled(_ev: ScheduledController, env: Env) {
585
+ // Two things this needs, and it says which one is missing rather than
586
+ // returning quietly: a cron that does nothing without explaining itself is a
587
+ // map that stops growing for a reason nobody can find. The placeholder counts
588
+ // as missing — "your-org" is what wrangler.toml ships with.
589
+ if (!env.GITHUB_TOKEN) {
590
+ console.log(
591
+ 'city: no GITHUB_TOKEN, skipping the pass. ' +
592
+ 'Set it with: npx wrangler@4 secret put GITHUB_TOKEN',
593
+ );
594
+ return;
595
+ }
596
+ if (!env.GITHUB_ORG || env.GITHUB_ORG === 'your-org') {
597
+ console.log(
598
+ 'city: GITHUB_ORG is not set, skipping the pass. ' +
599
+ 'Put your organisation in city/worker/wrangler.toml under [vars].',
600
+ );
601
+ return;
602
+ }
603
+
604
+ const REPOS_POR_TANDA = 6; // four queries each: 24 < the 30 per minute cap
605
+ const ahora = new Date().toISOString();
606
+
607
+ // One read for everything: repos and their parcels. This used to fetch the
608
+ // repo list and then, inside the loop, each repo's parcels.
609
+ const { results: todas } = await env.DB.prepare(
610
+ 'SELECT id, repo FROM parcela ORDER BY repo',
611
+ ).all<{ id: string; repo: string }>();
612
+ if (!todas.length) return;
613
+ const porRepo = new Map<string, string[]>();
614
+ for (const p of todas) {
615
+ const l = porRepo.get(p.repo);
616
+ l ? l.push(p.id) : porRepo.set(p.repo, [p.id]);
617
+ }
618
+ const repos = [...porRepo.keys()].map((repo) => ({ repo }));
619
+
620
+ const fila = await env.DB.prepare("SELECT valor FROM meta WHERE clave='cursor'").first<{
621
+ valor: string;
622
+ }>();
623
+ const cursor = Number(fila?.valor ?? 0) % repos.length;
624
+ const tanda = Array.from(
625
+ { length: Math.min(REPOS_POR_TANDA, repos.length) },
626
+ (_, i) => repos[(cursor + i) % repos.length].repo,
627
+ );
628
+
629
+ const hace = (dias: number) => new Date(Date.now() - dias * 864e5).toISOString().slice(0, 10);
630
+
631
+ /** total_count of a search. One call, one exact number. */
632
+ const cuantos = async (tipo: 'issues' | 'commits', q: string): Promise<number> => {
633
+ const r = await fetch(
634
+ `https://api.github.com/search/${tipo}?q=${encodeURIComponent(q)}&per_page=1`,
635
+ {
636
+ headers: {
637
+ authorization: `Bearer ${env.GITHUB_TOKEN}`,
638
+ accept: 'application/vnd.github+json',
639
+ 'user-agent': 'agents-city',
640
+ },
641
+ },
642
+ );
643
+ if (!r.ok) throw new Error(`search ${r.status}`);
644
+ const j = await r.json<{ total_count: number }>();
645
+ return j.total_count ?? 0;
646
+ };
647
+
648
+ for (const repo of tanda) {
649
+ const en = `repo:${env.GITHUB_ORG}/${repo}`;
650
+ try {
651
+ const [mergeados, abiertos, viejos, commits] = await Promise.all([
652
+ cuantos('issues', `${en} is:pr is:merged`),
653
+ cuantos('issues', `${en} is:pr is:open`),
654
+ cuantos('issues', `${en} is:pr is:open created:<${hace(14)}`),
655
+ cuantos('commits', `${en} committer-date:>${hace(30)}`),
656
+ ]);
657
+ // One prepared statement and a batch, not a write per parcel. A repo
658
+ // split into eight parcels meant eight round trips to D1 for that repo
659
+ // alone — hundreds per pass across an organisation.
660
+ const suyas = porRepo.get(repo) ?? [];
661
+ if (suyas.length) {
662
+ const stmt = env.DB.prepare(`
663
+ INSERT INTO casa (parcela_id,pisos,ladrillos,andamios,andamio_viejo,grieta,actividad30,actualizado)
664
+ VALUES (?,?,?,?,?,?,?,?)
665
+ ON CONFLICT(parcela_id) DO UPDATE SET
666
+ pisos=excluded.pisos, ladrillos=excluded.ladrillos, andamios=excluded.andamios,
667
+ andamio_viejo=excluded.andamio_viejo, actividad30=excluded.actividad30,
668
+ actualizado=excluded.actualizado`);
669
+ await env.DB.batch(
670
+ suyas.map((id) =>
671
+ stmt.bind(
672
+ id,
673
+ mergeados,
674
+ Math.max(0, commits - mergeados),
675
+ abiertos,
676
+ viejos,
677
+ 0,
678
+ commits,
679
+ ahora,
680
+ ),
681
+ ),
682
+ );
683
+ }
684
+ } catch (e) {
685
+ // One failing repo cannot take down the batch or stall the cursor: a repo
686
+ // moved to another organisation would freeze the map forever.
687
+ console.log(`city: ${repo} failed`, e);
688
+ }
689
+ }
690
+
691
+ // The cursor and the pruning in a single trip. Without pruning, `evento`
692
+ // grows forever: 90 days of history is more than anybody looks at.
693
+ await env.DB.batch([
694
+ env.DB.prepare(
695
+ "INSERT INTO meta (clave,valor) VALUES ('cursor',?) ON CONFLICT(clave) DO UPDATE SET valor=excluded.valor",
696
+ ).bind(String((cursor + tanda.length) % repos.length)),
697
+ env.DB.prepare('DELETE FROM evento WHERE ts < ?').bind(
698
+ new Date(Date.now() - 90 * 864e5).toISOString(),
699
+ ),
700
+ ]);
701
+ },
702
+ };