agents-city 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (449) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.es.md +2226 -0
  5. package/README.md +2196 -0
  6. package/benchmarks/committee/README.md +39 -0
  7. package/benchmarks/committee/metrics.py +51 -0
  8. package/benchmarks/committee/run.py +64 -0
  9. package/benchmarks/committee/traces/01-single.json +8 -0
  10. package/benchmarks/committee/traces/02-mesh.json +16 -0
  11. package/benchmarks/committee/traces/03-chair.json +15 -0
  12. package/benchmarks/latency/README.md +35 -0
  13. package/benchmarks/latency/fake-claude-cli.mjs +119 -0
  14. package/benchmarks/latency/fake-native-server.mjs +340 -0
  15. package/benchmarks/latency/fake-tui.py +91 -0
  16. package/benchmarks/latency/live.py +380 -0
  17. package/benchmarks/stress/README.md +20 -0
  18. package/benchmarks/stress/run.py +487 -0
  19. package/bin/agents +4 -0
  20. package/bin/agents-city.js +119 -0
  21. package/bin/benchmark +17 -0
  22. package/bin/bus +6 -0
  23. package/bin/cities +3 -0
  24. package/bin/city +86 -0
  25. package/bin/committee +6 -0
  26. package/bin/demo +200 -0
  27. package/bin/doctor +3 -0
  28. package/bin/exit +5 -0
  29. package/bin/hall +73 -0
  30. package/bin/hall.html +498 -0
  31. package/bin/logs +3 -0
  32. package/bin/navegador.mjs +243 -0
  33. package/bin/report.py +14 -0
  34. package/bin/reset +3 -0
  35. package/bin/road +3 -0
  36. package/bin/seat +10 -0
  37. package/bin/serve.py +1579 -0
  38. package/bin/setup.py +753 -0
  39. package/bin/shortcut +3 -0
  40. package/bin/skills +3 -0
  41. package/bin/test +133 -0
  42. package/bin/test-actualiza.py +130 -0
  43. package/bin/test-adapter.py +183 -0
  44. package/bin/test-admision.py +99 -0
  45. package/bin/test-atajos.py +301 -0
  46. package/bin/test-avatar.py +58 -0
  47. package/bin/test-benchmark.py +40 -0
  48. package/bin/test-broker.py +334 -0
  49. package/bin/test-cage.py +374 -0
  50. package/bin/test-card.py +402 -0
  51. package/bin/test-channel.py +379 -0
  52. package/bin/test-cities.py +453 -0
  53. package/bin/test-claude-runtime.py +252 -0
  54. package/bin/test-committee.py +340 -0
  55. package/bin/test-contracts.py +551 -0
  56. package/bin/test-crecimiento.py +164 -0
  57. package/bin/test-demo.py +308 -0
  58. package/bin/test-doctor.py +139 -0
  59. package/bin/test-domains.py +151 -0
  60. package/bin/test-evidencia.py +50 -0
  61. package/bin/test-exit.py +128 -0
  62. package/bin/test-hall-protocol.py +93 -0
  63. package/bin/test-launch.py +169 -0
  64. package/bin/test-live-feed.py +508 -0
  65. package/bin/test-navegador.py +140 -0
  66. package/bin/test-pairing.py +99 -0
  67. package/bin/test-parcels.py +283 -0
  68. package/bin/test-runtime-failures.py +252 -0
  69. package/bin/test-runtime-ui.py +488 -0
  70. package/bin/test-runtime.py +268 -0
  71. package/bin/test-rutas.py +80 -0
  72. package/bin/test-seat.py +1598 -0
  73. package/bin/test-security.py +150 -0
  74. package/bin/test-serve.py +1251 -0
  75. package/bin/test-stress.py +64 -0
  76. package/bin/test-widgets.py +143 -0
  77. package/bin/test-workspace.py +251 -0
  78. package/bin/testlib.py +118 -0
  79. package/bin/tokens.py +14 -0
  80. package/bin/update +3 -0
  81. package/bus/scripts/deploy.sh +60 -0
  82. package/bus/scripts/mint-token.sh +68 -0
  83. package/bus/scripts/setup-dev.sh +79 -0
  84. package/bus/scripts/test-channel.ts +8 -0
  85. package/bus/scripts/test-hub.ts +266 -0
  86. package/bus/scripts/test-local.sh +40 -0
  87. package/bus/scripts/test-queue.ts +132 -0
  88. package/bus/worker/package-lock.json +1971 -0
  89. package/bus/worker/package.json +17 -0
  90. package/bus/worker/src/index.ts +485 -0
  91. package/bus/worker/tsconfig.json +13 -0
  92. package/bus/worker/wrangler.toml +32 -0
  93. package/city/oven/README.md +59 -0
  94. package/city/oven/collect.py +43 -0
  95. package/city/oven/oven.html +4 -0
  96. package/city/scripts/history.py +120 -0
  97. package/city/scripts/seed.py +442 -0
  98. package/city/web/assets/modelos/License.txt +28 -0
  99. package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
  100. package/city/web/assets/modelos/building-a.glb +0 -0
  101. package/city/web/assets/modelos/building-b.glb +0 -0
  102. package/city/web/assets/modelos/building-c.glb +0 -0
  103. package/city/web/assets/modelos/building-d.glb +0 -0
  104. package/city/web/assets/modelos/building-e.glb +0 -0
  105. package/city/web/assets/modelos/building-f.glb +0 -0
  106. package/city/web/assets/modelos/building-g.glb +0 -0
  107. package/city/web/assets/modelos/building-h.glb +0 -0
  108. package/city/web/assets/sprites/building-a.png +0 -0
  109. package/city/web/assets/sprites/building-b.png +0 -0
  110. package/city/web/assets/sprites/building-c.png +0 -0
  111. package/city/web/assets/sprites/building-d.png +0 -0
  112. package/city/web/assets/sprites/building-e.png +0 -0
  113. package/city/web/assets/sprites/building-f.png +0 -0
  114. package/city/web/assets/sprites/building-g.png +0 -0
  115. package/city/web/assets/sprites/building-h.png +0 -0
  116. package/city/web/assets/sprites/building-i.png +0 -0
  117. package/city/web/assets/sprites/building-j.png +0 -0
  118. package/city/web/assets/sprites/building-k.png +0 -0
  119. package/city/web/assets/sprites/building-l.png +0 -0
  120. package/city/web/assets/sprites/building-m.png +0 -0
  121. package/city/web/assets/sprites/building-n.png +0 -0
  122. package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
  123. package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
  124. package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
  125. package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
  126. package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
  127. package/city/web/assets/sprites/catalogo.json +47 -0
  128. package/city/web/assets/sprites/detail-awning-wide.png +0 -0
  129. package/city/web/assets/sprites/detail-awning.png +0 -0
  130. package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
  131. package/city/web/assets/sprites/detail-overhang.png +0 -0
  132. package/city/web/assets/sprites/detail-parasol-a.png +0 -0
  133. package/city/web/assets/sprites/detail-parasol-b.png +0 -0
  134. package/city/web/assets/sprites/low-detail-building-a.png +0 -0
  135. package/city/web/assets/sprites/low-detail-building-b.png +0 -0
  136. package/city/web/assets/sprites/low-detail-building-c.png +0 -0
  137. package/city/web/assets/sprites/low-detail-building-d.png +0 -0
  138. package/city/web/assets/sprites/low-detail-building-e.png +0 -0
  139. package/city/web/assets/sprites/low-detail-building-f.png +0 -0
  140. package/city/web/assets/sprites/low-detail-building-g.png +0 -0
  141. package/city/web/assets/sprites/low-detail-building-h.png +0 -0
  142. package/city/web/assets/sprites/low-detail-building-i.png +0 -0
  143. package/city/web/assets/sprites/low-detail-building-j.png +0 -0
  144. package/city/web/assets/sprites/low-detail-building-k.png +0 -0
  145. package/city/web/assets/sprites/low-detail-building-l.png +0 -0
  146. package/city/web/assets/sprites/low-detail-building-m.png +0 -0
  147. package/city/web/assets/sprites/low-detail-building-n.png +0 -0
  148. package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
  149. package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
  150. package/city/web/assets/sprites/medidas.json +248 -0
  151. package/city/web/dist/city.js +939 -0
  152. package/city/web/dist/index.html +294 -0
  153. package/city/web/dist/modelos/License.txt +28 -0
  154. package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
  155. package/city/web/dist/modelos/building-a.glb +0 -0
  156. package/city/web/dist/modelos/building-b.glb +0 -0
  157. package/city/web/dist/modelos/building-c.glb +0 -0
  158. package/city/web/dist/modelos/building-d.glb +0 -0
  159. package/city/web/dist/modelos/building-e.glb +0 -0
  160. package/city/web/dist/modelos/building-f.glb +0 -0
  161. package/city/web/dist/modelos/building-g.glb +0 -0
  162. package/city/web/dist/modelos/building-h.glb +0 -0
  163. package/city/web/dist/sprites/building-a.png +0 -0
  164. package/city/web/dist/sprites/building-b.png +0 -0
  165. package/city/web/dist/sprites/building-c.png +0 -0
  166. package/city/web/dist/sprites/building-d.png +0 -0
  167. package/city/web/dist/sprites/building-e.png +0 -0
  168. package/city/web/dist/sprites/building-f.png +0 -0
  169. package/city/web/dist/sprites/building-g.png +0 -0
  170. package/city/web/dist/sprites/building-h.png +0 -0
  171. package/city/web/dist/sprites/building-i.png +0 -0
  172. package/city/web/dist/sprites/building-j.png +0 -0
  173. package/city/web/dist/sprites/building-k.png +0 -0
  174. package/city/web/dist/sprites/building-l.png +0 -0
  175. package/city/web/dist/sprites/building-m.png +0 -0
  176. package/city/web/dist/sprites/building-n.png +0 -0
  177. package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
  178. package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
  179. package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
  180. package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
  181. package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
  182. package/city/web/dist/sprites/catalogo.json +47 -0
  183. package/city/web/dist/sprites/detail-awning-wide.png +0 -0
  184. package/city/web/dist/sprites/detail-awning.png +0 -0
  185. package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
  186. package/city/web/dist/sprites/detail-overhang.png +0 -0
  187. package/city/web/dist/sprites/detail-parasol-a.png +0 -0
  188. package/city/web/dist/sprites/detail-parasol-b.png +0 -0
  189. package/city/web/dist/sprites/low-detail-building-a.png +0 -0
  190. package/city/web/dist/sprites/low-detail-building-b.png +0 -0
  191. package/city/web/dist/sprites/low-detail-building-c.png +0 -0
  192. package/city/web/dist/sprites/low-detail-building-d.png +0 -0
  193. package/city/web/dist/sprites/low-detail-building-e.png +0 -0
  194. package/city/web/dist/sprites/low-detail-building-f.png +0 -0
  195. package/city/web/dist/sprites/low-detail-building-g.png +0 -0
  196. package/city/web/dist/sprites/low-detail-building-h.png +0 -0
  197. package/city/web/dist/sprites/low-detail-building-i.png +0 -0
  198. package/city/web/dist/sprites/low-detail-building-j.png +0 -0
  199. package/city/web/dist/sprites/low-detail-building-k.png +0 -0
  200. package/city/web/dist/sprites/low-detail-building-l.png +0 -0
  201. package/city/web/dist/sprites/low-detail-building-m.png +0 -0
  202. package/city/web/dist/sprites/low-detail-building-n.png +0 -0
  203. package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
  204. package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
  205. package/city/web/dist/sprites/medidas.json +248 -0
  206. package/city/web/dist-hall/hall.js +1363 -0
  207. package/city/web/index.html +294 -0
  208. package/city/web/package.json +18 -0
  209. package/city/web/sella.py +21 -0
  210. package/city/web/src/activity-actors.ts +47 -0
  211. package/city/web/src/activity.ts +172 -0
  212. package/city/web/src/ayuntamiento.ts +580 -0
  213. package/city/web/src/draw.ts +640 -0
  214. package/city/web/src/game-speech.ts +79 -0
  215. package/city/web/src/hall.ts +1959 -0
  216. package/city/web/src/main.ts +2252 -0
  217. package/city/web/src/oven.ts +124 -0
  218. package/city/web/src/people.ts +244 -0
  219. package/city/web/src/presencia.ts +193 -0
  220. package/city/web/src/puertas.ts +180 -0
  221. package/city/web/src/repo-roles.ts +59 -0
  222. package/city/worker/package.json +16 -0
  223. package/city/worker/schema.sql +117 -0
  224. package/city/worker/src/index.ts +702 -0
  225. package/city/worker/src/square.ts +218 -0
  226. package/city/worker/wrangler.toml +58 -0
  227. package/demo/ada.md +50 -0
  228. package/demo/bruno.md +62 -0
  229. package/demo/camila.md +56 -0
  230. package/demo/city/ada.md +40 -0
  231. package/demo/city/city.yml +8 -0
  232. package/demo/city/parcels.yml +15 -0
  233. package/demo/city/roads.json +4 -0
  234. package/demo/city/units.yml +10 -0
  235. package/demo/clinica/city.yml +8 -0
  236. package/demo/clinica/parcels.yml +15 -0
  237. package/demo/clinica/roads.json +4 -0
  238. package/demo/clinica/units.yml +9 -0
  239. package/demo/clinica/vera.md +46 -0
  240. package/demo/dante.md +54 -0
  241. package/demo/despacho/city.yml +8 -0
  242. package/demo/despacho/marta.md +46 -0
  243. package/demo/despacho/parcels.yml +15 -0
  244. package/demo/despacho/roads.json +4 -0
  245. package/demo/despacho/units.yml +9 -0
  246. package/demo/elsa.md +50 -0
  247. package/demo/farid.md +52 -0
  248. package/demo/greta.md +50 -0
  249. package/demo/hugo.md +49 -0
  250. package/demo/iris.md +49 -0
  251. package/demo/jonas.md +51 -0
  252. package/demo/kira.md +51 -0
  253. package/demo/luca.md +49 -0
  254. package/demo/parcels.yml +92 -0
  255. package/demo/seed.py +20 -0
  256. package/demo/show.py +161 -0
  257. package/demo/stories.py +824 -0
  258. package/demo/units.yml +33 -0
  259. package/docs/agents-first.md +106 -0
  260. package/docs/glossary.md +55 -0
  261. package/docs/map-live-layers.md +73 -0
  262. package/docs/security.md +265 -0
  263. package/docs/self-host.md +151 -0
  264. package/docs/testing.md +205 -0
  265. package/package.json +73 -0
  266. package/plugin/.claude-plugin/plugin.json +62 -0
  267. package/plugin/.mcp.json +15 -0
  268. package/plugin/channel/activity-cli.ts +16 -0
  269. package/plugin/channel/adapter-prompts.ts +113 -0
  270. package/plugin/channel/adapter.js +4292 -0
  271. package/plugin/channel/adapter.ts +133 -0
  272. package/plugin/channel/bus.js +20188 -0
  273. package/plugin/channel/bus.ts +131 -0
  274. package/plugin/channel/city-config.ts +125 -0
  275. package/plugin/channel/claude-channel.ts +58 -0
  276. package/plugin/channel/cli-args.ts +75 -0
  277. package/plugin/channel/client.js +4200 -0
  278. package/plugin/channel/client.ts +38 -0
  279. package/plugin/channel/committee/activity.ts +256 -0
  280. package/plugin/channel/committee/collection.ts +154 -0
  281. package/plugin/channel/committee/decision.ts +175 -0
  282. package/plugin/channel/committee/floor.ts +135 -0
  283. package/plugin/channel/committee/guards.ts +30 -0
  284. package/plugin/channel/committee/history.ts +38 -0
  285. package/plugin/channel/committee/render.ts +102 -0
  286. package/plugin/channel/committee/service.ts +72 -0
  287. package/plugin/channel/committee/storage.ts +92 -0
  288. package/plugin/channel/committee/types.ts +163 -0
  289. package/plugin/channel/committee/view.ts +63 -0
  290. package/plugin/channel/committee-cli.ts +109 -0
  291. package/plugin/channel/delivery-metrics.ts +61 -0
  292. package/plugin/channel/delivery-queue.ts +118 -0
  293. package/plugin/channel/hub/activity-controller.ts +72 -0
  294. package/plugin/channel/hub/activity-feed.ts +169 -0
  295. package/plugin/channel/hub/committee-controller.ts +60 -0
  296. package/plugin/channel/hub/connections.ts +48 -0
  297. package/plugin/channel/hub/diagnostics.ts +67 -0
  298. package/plugin/channel/hub/envelope-validity.ts +109 -0
  299. package/plugin/channel/hub/envelopes.ts +66 -0
  300. package/plugin/channel/hub/lifecycle.ts +91 -0
  301. package/plugin/channel/hub/local-roads.ts +89 -0
  302. package/plugin/channel/hub/remote-roads.ts +165 -0
  303. package/plugin/channel/hub/road-controller.ts +99 -0
  304. package/plugin/channel/hub-client.ts +149 -0
  305. package/plugin/channel/local-hub.js +5995 -0
  306. package/plugin/channel/local-hub.ts +349 -0
  307. package/plugin/channel/map-reporter.ts +48 -0
  308. package/plugin/channel/package-lock.json +1731 -0
  309. package/plugin/channel/package.json +19 -0
  310. package/plugin/channel/protocol.ts +98 -0
  311. package/plugin/channel/road-cli.ts +18 -0
  312. package/plugin/channel/run.sh +15 -0
  313. package/plugin/channel/runtime/claude.ts +458 -0
  314. package/plugin/channel/runtime/codex-config.ts +77 -0
  315. package/plugin/channel/runtime/codex.ts +669 -0
  316. package/plugin/channel/runtime/command.ts +62 -0
  317. package/plugin/channel/runtime/factory.ts +15 -0
  318. package/plugin/channel/runtime/json-rpc.ts +154 -0
  319. package/plugin/channel/runtime/kimi.ts +277 -0
  320. package/plugin/channel/runtime/opencode.ts +248 -0
  321. package/plugin/channel/runtime/process.ts +84 -0
  322. package/plugin/channel/runtime/types.ts +45 -0
  323. package/plugin/channel/runtime-files.ts +73 -0
  324. package/plugin/channel/runtime-gateway.js +6396 -0
  325. package/plugin/channel/runtime-gateway.ts +358 -0
  326. package/plugin/channel/runtime-metrics.ts +52 -0
  327. package/plugin/channel/runtime-subscription.ts +180 -0
  328. package/plugin/channel/terminal-delivery.ts +131 -0
  329. package/plugin/channel/untrusted.ts +60 -0
  330. package/plugin/commands/committee.md +31 -0
  331. package/plugin/commands/exit.md +32 -0
  332. package/plugin/commands/goals.md +20 -0
  333. package/plugin/commands/join.md +18 -0
  334. package/plugin/commands/notice.md +20 -0
  335. package/plugin/commands/propose.md +19 -0
  336. package/plugin/commands/round.md +17 -0
  337. package/plugin/commands/session.md +22 -0
  338. package/plugin/commands/settings.md +25 -0
  339. package/plugin/commands/setup.md +21 -0
  340. package/plugin/commands/team.md +23 -0
  341. package/plugin/domains/custom.md +27 -0
  342. package/plugin/domains/finance.md +31 -0
  343. package/plugin/domains/healthcare.md +34 -0
  344. package/plugin/domains/legal.md +31 -0
  345. package/plugin/domains/marketing.md +33 -0
  346. package/plugin/domains/operations.md +31 -0
  347. package/plugin/domains/research.md +32 -0
  348. package/plugin/domains/sales.md +30 -0
  349. package/plugin/domains/software.md +37 -0
  350. package/plugin/hooks/activity.sh +6 -0
  351. package/plugin/hooks/digging.sh +68 -0
  352. package/plugin/hooks/growth.sh +42 -0
  353. package/plugin/hooks/hooks.json +106 -0
  354. package/plugin/hooks/notice-on-pr.sh +35 -0
  355. package/plugin/hooks/notice-on-stop.sh +80 -0
  356. package/plugin/hooks/notice-pending.sh +58 -0
  357. package/plugin/hooks/solo-en-ciudad.sh +23 -0
  358. package/plugin/hooks/tokens.sh +47 -0
  359. package/plugin/roles/examples/account-executive.md +25 -0
  360. package/plugin/roles/examples/ai-manager.md +34 -0
  361. package/plugin/roles/examples/associate.md +24 -0
  362. package/plugin/roles/examples/brand-lead.md +20 -0
  363. package/plugin/roles/examples/cfo.md +20 -0
  364. package/plugin/roles/examples/city-lead.md +25 -0
  365. package/plugin/roles/examples/clinical-director.md +26 -0
  366. package/plugin/roles/examples/clinical-ops.md +25 -0
  367. package/plugin/roles/examples/clinician.md +25 -0
  368. package/plugin/roles/examples/compliance.md +27 -0
  369. package/plugin/roles/examples/content.md +24 -0
  370. package/plugin/roles/examples/controller.md +27 -0
  371. package/plugin/roles/examples/cpto.md +41 -0
  372. package/plugin/roles/examples/customer-success.md +25 -0
  373. package/plugin/roles/examples/data-engineer.md +37 -0
  374. package/plugin/roles/examples/data.md +60 -0
  375. package/plugin/roles/examples/dev.md +37 -0
  376. package/plugin/roles/examples/devops.md +39 -0
  377. package/plugin/roles/examples/enablement.md +24 -0
  378. package/plugin/roles/examples/ethics.md +24 -0
  379. package/plugin/roles/examples/fin-analytics.md +27 -0
  380. package/plugin/roles/examples/health-compliance.md +25 -0
  381. package/plugin/roles/examples/health-data.md +26 -0
  382. package/plugin/roles/examples/knowledge.md +25 -0
  383. package/plugin/roles/examples/lifecycle.md +26 -0
  384. package/plugin/roles/examples/llm-engineer.md +34 -0
  385. package/plugin/roles/examples/managing-partner.md +20 -0
  386. package/plugin/roles/examples/methods.md +23 -0
  387. package/plugin/roles/examples/operations-lead.md +23 -0
  388. package/plugin/roles/examples/ops.md +24 -0
  389. package/plugin/roles/examples/patient-safety.md +26 -0
  390. package/plugin/roles/examples/performance.md +27 -0
  391. package/plugin/roles/examples/po.md +36 -0
  392. package/plugin/roles/examples/process-owner.md +23 -0
  393. package/plugin/roles/examples/product-design.md +39 -0
  394. package/plugin/roles/examples/program-manager.md +24 -0
  395. package/plugin/roles/examples/quality.md +25 -0
  396. package/plugin/roles/examples/research-director.md +24 -0
  397. package/plugin/roles/examples/research-ops.md +25 -0
  398. package/plugin/roles/examples/researcher.md +25 -0
  399. package/plugin/roles/examples/revenue-lead.md +25 -0
  400. package/plugin/roles/examples/revops.md +26 -0
  401. package/plugin/roles/examples/seo.md +27 -0
  402. package/plugin/roles/examples/specialist.md +24 -0
  403. package/plugin/scripts/actualiza.py +198 -0
  404. package/plugin/scripts/admision.py +165 -0
  405. package/plugin/scripts/apaga.py +232 -0
  406. package/plugin/scripts/atajos.py +506 -0
  407. package/plugin/scripts/avatar.py +211 -0
  408. package/plugin/scripts/broker.py +531 -0
  409. package/plugin/scripts/cage.py +492 -0
  410. package/plugin/scripts/capabilities.py +210 -0
  411. package/plugin/scripts/card.py +322 -0
  412. package/plugin/scripts/cities.py +520 -0
  413. package/plugin/scripts/city-env.sh +90 -0
  414. package/plugin/scripts/city-runtime.sh +87 -0
  415. package/plugin/scripts/city-session.sh +552 -0
  416. package/plugin/scripts/city_env.py +67 -0
  417. package/plugin/scripts/crecimiento.py +140 -0
  418. package/plugin/scripts/deliberations.py +48 -0
  419. package/plugin/scripts/doctor.py +294 -0
  420. package/plugin/scripts/domains.py +201 -0
  421. package/plugin/scripts/evidencia.py +64 -0
  422. package/plugin/scripts/find-repos.sh +111 -0
  423. package/plugin/scripts/gh.py +159 -0
  424. package/plugin/scripts/hall_protocol.py +147 -0
  425. package/plugin/scripts/hook_activity.py +139 -0
  426. package/plugin/scripts/launch.py +94 -0
  427. package/plugin/scripts/logs.py +98 -0
  428. package/plugin/scripts/pairing.py +185 -0
  429. package/plugin/scripts/parcels.py +132 -0
  430. package/plugin/scripts/read-card.py +55 -0
  431. package/plugin/scripts/report.py +201 -0
  432. package/plugin/scripts/reset.py +180 -0
  433. package/plugin/scripts/roads.py +230 -0
  434. package/plugin/scripts/roles.py +274 -0
  435. package/plugin/scripts/runtime_log.py +69 -0
  436. package/plugin/scripts/runtime_processes.py +132 -0
  437. package/plugin/scripts/rutas.py +83 -0
  438. package/plugin/scripts/seat.py +1696 -0
  439. package/plugin/scripts/tokens.py +194 -0
  440. package/plugin/scripts/trust-repos.py +59 -0
  441. package/plugin/scripts/ui.py +280 -0
  442. package/plugin/scripts/units.py +80 -0
  443. package/plugin/scripts/workspace.py +561 -0
  444. package/plugin/skills/city/SKILL.md +191 -0
  445. package/templates/blank.md +39 -0
  446. package/templates/finance.md +44 -0
  447. package/templates/legal.md +45 -0
  448. package/templates/marketing.md +48 -0
  449. package/templates/product.md +45 -0
@@ -0,0 +1,1959 @@
1
+ /* The town hall's front end.
2
+ *
3
+ * Typed and built by the same esbuild and the same `tsc --strict` as the map,
4
+ * because the first version was 400 lines of untyped template literals inline in
5
+ * the HTML — and it shipped broken once, on an escaped apostrophe the compiler
6
+ * would have caught before any browser saw it.
7
+ *
8
+ * It builds to dist-hall/, not dist/: dist/ is what the deployed Worker serves to
9
+ * the active city, and the hall is local by design — it drives an API that writes
10
+ * this machine's disk. The Python server (bin/serve.py) is the only thing that
11
+ * ever serves this file.
12
+ */
13
+
14
+ import {
15
+ ActivityEvent,
16
+ MAP_ACTIVITY_PROTOCOL,
17
+ isActivityEvent as esActividad,
18
+ isMapNavMessage as esNavDeMapa,
19
+ isPresenceEvent,
20
+ isSpeechEvent,
21
+ } from './activity';
22
+
23
+ // ── what the API answers ─────────────────────────────────────────────────────
24
+ interface Objetivo {
25
+ user?: string;
26
+ title: string;
27
+ signal: string;
28
+ command: string;
29
+ manual?: string;
30
+ baseline: string;
31
+ target: string;
32
+ by: string;
33
+ }
34
+ interface Tarjeta {
35
+ user: string;
36
+ name: string;
37
+ role: string;
38
+ agent: string;
39
+ repos: string[];
40
+ repo_roles: Record<string, string>;
41
+ goals_defined: boolean;
42
+ objetivo: Objetivo | null;
43
+ }
44
+ interface Unidad {
45
+ id: string;
46
+ name: string;
47
+ color: string;
48
+ }
49
+ interface ParcelaFila {
50
+ id: string;
51
+ repo: string;
52
+ ruta: string;
53
+ unidad: string;
54
+ nombre: string;
55
+ }
56
+ interface Color {
57
+ hex: string;
58
+ nombre: string;
59
+ }
60
+ interface Ciudad {
61
+ ruta: string;
62
+ nombre: string;
63
+ slug?: string;
64
+ id?: string;
65
+ actual: boolean;
66
+ }
67
+ interface Road {
68
+ id: string;
69
+ name: string;
70
+ owner: string;
71
+ address: string;
72
+ local?: boolean;
73
+ domain?: string;
74
+ role?: string;
75
+ }
76
+ interface SkillInfo {
77
+ name: string;
78
+ description: string;
79
+ manifest: string;
80
+ provider: string;
81
+ /** Present only when this skill lives in the agent home's own
82
+ * `.claude/skills` — the one place the Hall installs into and may remove
83
+ * from. `dir` is the folder name removal is keyed on; the display name
84
+ * above is the SKILL.md's word and free to differ. */
85
+ removable?: boolean;
86
+ dir?: string;
87
+ }
88
+ interface AgentCapabilities {
89
+ path: string;
90
+ role?: string;
91
+ skills: SkillInfo[];
92
+ }
93
+ /** One agent's character sheet, as the server builds it: identity, engine and
94
+ * growth — every number real, none invented for the card to look fuller. */
95
+ interface FichaAgente {
96
+ name: string;
97
+ slug: string;
98
+ kind: string;
99
+ role: string;
100
+ runtime: string;
101
+ model: string;
102
+ effort: string;
103
+ avatar_seed: string;
104
+ avatar: string;
105
+ cli: { binary: string; installed: boolean; connected: boolean };
106
+ legacy: boolean;
107
+ /** What this agent actually works on — repos, worktrees, document folders.
108
+ * `fixed` is a legacy repo agent's own repo, which is not a mount it chose. */
109
+ mounts: { label: string; target: string; fixed?: boolean }[];
110
+ growth: { floors: number; bricks: number; activity30: number; signal: string };
111
+ }
112
+ interface Deliberation {
113
+ id: string;
114
+ status: string;
115
+ question: string;
116
+ desired_outcome: string;
117
+ participants: string[];
118
+ received: number;
119
+ total: number;
120
+ revision: number;
121
+ decision: string;
122
+ contributors: string[];
123
+ verifier: string;
124
+ verification: string;
125
+ updated_at: string;
126
+ act: string;
127
+ }
128
+ interface LiveBus {
129
+ online: boolean;
130
+ url: string;
131
+ city: string;
132
+ started_at: string;
133
+ }
134
+ interface Invitation {
135
+ version: number;
136
+ id: string;
137
+ name: string;
138
+ owner: string;
139
+ address: string;
140
+ domain: string;
141
+ role?: string;
142
+ }
143
+ interface Estado {
144
+ datos: string;
145
+ casa: string;
146
+ yo: string;
147
+ address: string;
148
+ city_id: string;
149
+ city_name: string;
150
+ domain: string;
151
+ kind: string;
152
+ grow: string;
153
+ tarjetas: Tarjeta[];
154
+ unidades: Unidad[];
155
+ parcelas: ParcelaFila[];
156
+ lab: string[];
157
+ gh: boolean;
158
+ tmux: string[];
159
+ plugin: boolean;
160
+ mapa: string;
161
+ paleta: Color[];
162
+ sesion: string;
163
+ ciudades: Ciudad[];
164
+ roads: Road[];
165
+ invitation: Invitation;
166
+ skills: Record<string, AgentCapabilities>;
167
+ deliberations: Deliberation[];
168
+ live_bus: LiveBus;
169
+ /** name → identicon data URI, kind-tinted. Server-drawn (avatar.py) so the
170
+ * face is the same one everywhere, forever. */
171
+ avatars?: Record<string, string>;
172
+ /** The normalised agent list, as full character sheets. */
173
+ agents?: FichaAgente[];
174
+ /** True only for the packaged Aurora demo: unlocks the replay controls. */
175
+ demo?: boolean;
176
+ /** One line when a newer release is published, '' otherwise. */
177
+ update?: string;
178
+ }
179
+ interface RolInfo {
180
+ id: string;
181
+ name: string;
182
+ summary: string;
183
+ trade: string;
184
+ }
185
+ interface DomainInfo {
186
+ id: string;
187
+ name: string;
188
+ summary: string;
189
+ }
190
+ interface RepoLocal {
191
+ nombre: string;
192
+ ruta: string;
193
+ cuando: string;
194
+ mio: boolean;
195
+ }
196
+ interface Respuesta {
197
+ ok?: boolean;
198
+ error?: string;
199
+ user?: string;
200
+ attach?: string;
201
+ city?: string;
202
+ roads?: Road[];
203
+ }
204
+
205
+ declare global {
206
+ interface Window {
207
+ PASE: string;
208
+ }
209
+ }
210
+
211
+ // ── plumbing ─────────────────────────────────────────────────────────────────
212
+ const PASE = window.PASE;
213
+ const CIUDAD = new URLSearchParams(location.search).get('city') ?? '';
214
+
215
+ async function api<T>(ruta: string, opts?: RequestInit): Promise<T> {
216
+ let u = ruta + (ruta.includes('?') ? '&' : '?') + 'PASE=' + encodeURIComponent(PASE);
217
+ if (CIUDAD) u += '&city=' + encodeURIComponent(CIUDAD);
218
+ const r = await fetch(u, {
219
+ headers: { 'X-City-Pase': PASE, 'Content-Type': 'application/json' },
220
+ ...opts,
221
+ });
222
+ return r.json() as Promise<T>;
223
+ }
224
+
225
+ /** querySelector that refuses to hand back null: a missing element here is a bug
226
+ * in this file, and an early loud throw beats a silent dead button. */
227
+ function q<T extends Element>(sel: string, raiz: ParentNode = document): T {
228
+ const el = raiz.querySelector<T>(sel);
229
+ if (!el) throw new Error(`no element matches ${sel}`);
230
+ return el;
231
+ }
232
+ function todos<T extends Element>(sel: string, raiz: ParentNode = document): T[] {
233
+ return [...raiz.querySelectorAll<T>(sel)];
234
+ }
235
+
236
+ function esc(s: unknown): string {
237
+ return String(s ?? '').replace(
238
+ /[&<>"']/g,
239
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string,
240
+ );
241
+ }
242
+ const corto = (p: string): string => p.replace(E.casa, '~');
243
+
244
+ let toastTimer = 0;
245
+ function toast(msg: string, mal = false): void {
246
+ const t = q<HTMLElement>('#toast');
247
+ t.textContent = msg;
248
+ t.className = 'ver' + (mal ? ' mal' : '');
249
+ clearTimeout(toastTimer);
250
+ toastTimer = window.setTimeout(() => {
251
+ t.className = '';
252
+ }, 3200);
253
+ }
254
+ const espera = (ms: number) => new Promise((r) => setTimeout(r, ms));
255
+
256
+ // ── state ────────────────────────────────────────────────────────────────────
257
+ let E: Estado;
258
+ // A city is a place, not a dashboard. Open on the map so the centre always
259
+ // belongs to the city; configuration and the full transcript stay in the two
260
+ // Hall sidebars. The other sections remain explicit destinations in the rail.
261
+ let SECCION = 'mapa';
262
+ const SECCIONES: Array<[string, string]> = [
263
+ ['resumen', 'Overview'],
264
+ ['mapa', 'The map'],
265
+ ['puesto', 'My seat'],
266
+ ['barrios', 'Districts & houses'],
267
+ ['red', 'Roads'],
268
+ ['committee', 'Committee'],
269
+ ['gente', 'Agents & skills'],
270
+ ];
271
+
272
+ async function refresca(): Promise<void> {
273
+ E = await api<Estado>('/api/estado');
274
+ pinta();
275
+ pintaAvisoDeVersion();
276
+ pintaDemoControles();
277
+ sincronizaActividad(E.live_bus);
278
+ }
279
+
280
+ /**
281
+ * The demo's remote control, in the live rail: replay, pause, resume. The
282
+ * guided committee plays once when the first spectator connects, and anybody
283
+ * whose map was still baking missed it — these buttons are the way back. Real
284
+ * cities never see them, and the server refuses them anyway.
285
+ */
286
+ /** A newer release, said once and quietly: the server already decided whether
287
+ * there is one (cached for a day), so this only renders what it was told. */
288
+ function pintaAvisoDeVersion(): void {
289
+ const caja = document.getElementById('avisoVersion');
290
+ if (!caja) return;
291
+ const texto = (E.update ?? '').trim();
292
+ caja.textContent = texto;
293
+ caja.hidden = !texto;
294
+ }
295
+
296
+ function pintaDemoControles(): void {
297
+ const header = document.querySelector<HTMLElement>('#livePanel header');
298
+ const previa = document.getElementById('demoControles');
299
+ if (!header || !E.demo) {
300
+ previa?.remove();
301
+ return;
302
+ }
303
+ if (previa) return; // already wired; the buttons keep their own state
304
+ const caja = document.createElement('div');
305
+ caja.id = 'demoControles';
306
+ caja.innerHTML =
307
+ `<span>guided committee</span>` +
308
+ `<button id="demoReplay" type="button" title="Play the guided committee from the top">⟳ replay</button>` +
309
+ `<button id="demoPausa" type="button" title="Pause or resume mid-scene">⏸ pause</button>`;
310
+ header.appendChild(caja);
311
+ const pausa = q<HTMLButtonElement>('#demoPausa', caja);
312
+ const manda = (action: string) =>
313
+ api<{ ok?: boolean; running?: boolean; paused?: boolean }>('/api/demo', {
314
+ method: 'POST',
315
+ body: JSON.stringify({ action }),
316
+ });
317
+ q<HTMLButtonElement>('#demoReplay', caja).onclick = async () => {
318
+ const r = await manda('restart');
319
+ pausa.textContent = '⏸ pause';
320
+ if (!r.ok) toast('Could not restart the demo', true);
321
+ };
322
+ pausa.onclick = async () => {
323
+ const antes = pausa.textContent?.includes('resume') ?? false;
324
+ const r = await manda(antes ? 'resume' : 'pause');
325
+ if (!r.running) {
326
+ pausa.textContent = '⏸ pause';
327
+ toast('Nothing playing — hit replay', true);
328
+ return;
329
+ }
330
+ pausa.textContent = r.paused ? '▶ resume' : '⏸ pause';
331
+ };
332
+ }
333
+
334
+ // ── live, read-only WebSocket feed ──────────────────────────────────────────
335
+ let liveSocket: WebSocket | null = null;
336
+ let liveUrl = '';
337
+ let liveEvents: ActivityEvent[] = [];
338
+ let liveConnected = false;
339
+ let liveRetry = 0;
340
+ let liveSelectedThread: string | null = null;
341
+ let liveFilterTouched = false;
342
+ let liveShowWork = false;
343
+ let pendingMapActivity: ActivityEvent | null = null;
344
+
345
+ function sincronizaActividad(endpoint: LiveBus): void {
346
+ if (!endpoint?.online || !endpoint.url) {
347
+ liveConnected = false;
348
+ pintaActividad();
349
+ programaDescubrimiento();
350
+ return;
351
+ }
352
+ if (
353
+ endpoint.url === liveUrl &&
354
+ liveSocket &&
355
+ (liveSocket.readyState === WebSocket.OPEN || liveSocket.readyState === WebSocket.CONNECTING)
356
+ ) {
357
+ return;
358
+ }
359
+ const anterior = liveSocket;
360
+ if (anterior) {
361
+ anterior.onclose = null;
362
+ anterior.close();
363
+ }
364
+ liveUrl = endpoint.url;
365
+ const socket = new WebSocket(endpoint.url);
366
+ liveSocket = socket;
367
+ liveConnected = false;
368
+ pintaActividad();
369
+ socket.onopen = () => {
370
+ if (liveSocket !== socket) return;
371
+ liveConnected = true;
372
+ liveRetry = 0;
373
+ pintaActividad();
374
+ };
375
+ socket.onmessage = (message) => {
376
+ if (liveSocket !== socket) return;
377
+ let value: Record<string, unknown>;
378
+ try {
379
+ value = JSON.parse(String(message.data)) as Record<string, unknown>;
380
+ } catch {
381
+ return;
382
+ }
383
+ if (value.type === 'activity.state') {
384
+ liveEvents = Array.isArray(value.events)
385
+ ? (value.events as ActivityEvent[]).filter(esActividad)
386
+ : [];
387
+ liveEvents.sort((a, b) => a.seq - b.seq);
388
+ if (liveSelectedThread === null) liveSelectedThread = newestConversationThread();
389
+ pintaActividad(true);
390
+ } else if (value.type === 'activity.event' && esActividad(value.event)) {
391
+ const event = value.event;
392
+ if (!liveEvents.some((item) => item.id === event.id)) liveEvents.push(event);
393
+ liveEvents = liveEvents.sort((a, b) => a.seq - b.seq).slice(-200);
394
+ if (!liveFilterTouched && event.kind === 'committee.opened' && event.thread) {
395
+ liveSelectedThread = event.thread;
396
+ }
397
+ pintaActividad(true);
398
+ enviaActividadAlMapa(event);
399
+ }
400
+ };
401
+ socket.onclose = () => {
402
+ if (liveSocket !== socket) return;
403
+ liveSocket = null;
404
+ liveConnected = false;
405
+ pintaActividad();
406
+ programaDescubrimiento();
407
+ };
408
+ socket.onerror = () => {};
409
+ }
410
+
411
+ /** The Hall keeps the full transcript in its right rail. The map gets the same
412
+ * semantic event for a short, anchored game bubble — and the lifecycle beats,
413
+ * which never become bubbles but drive the map's live lights. */
414
+ function enviaActividadAlMapa(event: ActivityEvent): void {
415
+ const esComite = event.kind.startsWith('committee.');
416
+ if (!isSpeechEvent(event) && !isPresenceEvent(event) && !esComite) return;
417
+ const frame = document.querySelector<HTMLIFrameElement>('#cityMapFrame');
418
+ if (!frame) return;
419
+ if (frame.dataset.ready !== '1') {
420
+ pendingMapActivity = event;
421
+ return;
422
+ }
423
+ const origin = new URL(frame.src, location.href).origin;
424
+ frame.contentWindow?.postMessage(
425
+ { protocol: MAP_ACTIVITY_PROTOCOL, type: 'activity.event', event },
426
+ origin,
427
+ );
428
+ }
429
+
430
+ function programaDescubrimiento(): void {
431
+ if (liveRetry) return;
432
+ liveRetry = window.setTimeout(async () => {
433
+ liveRetry = 0;
434
+ try {
435
+ sincronizaActividad(await api<LiveBus>('/api/live'));
436
+ } catch {
437
+ programaDescubrimiento();
438
+ }
439
+ }, 1_500);
440
+ }
441
+
442
+ function pintaActividad(seguir = false): void {
443
+ const dot = document.querySelector<HTMLElement>('#liveDot');
444
+ const state = document.querySelector<HTMLElement>('#liveState');
445
+ const list = document.querySelector<HTMLOListElement>('#liveEvents');
446
+ const filter = document.querySelector<HTMLSelectElement>('#liveFilter');
447
+ const context = document.querySelector<HTMLElement>('#liveContext');
448
+ const workToggle = document.querySelector<HTMLButtonElement>('#liveWorkToggle');
449
+ if (!dot || !state || !list || !filter || !context || !workToggle) return;
450
+ dot.classList.toggle('on', liveConnected);
451
+ state.textContent = liveConnected
452
+ ? 'websocket live'
453
+ : liveUrl
454
+ ? 'reconnecting'
455
+ : 'session offline';
456
+
457
+ const opened = new Map<string, string>();
458
+ for (const event of liveEvents) {
459
+ if (
460
+ event.thread &&
461
+ (event.kind === 'committee.opened' || event.kind === 'conversation.user') &&
462
+ !esPromptDeTransporte(event) &&
463
+ !opened.has(event.thread)
464
+ ) {
465
+ opened.set(event.thread, event.summary);
466
+ }
467
+ }
468
+ filter.innerHTML =
469
+ '<option value="">all conversations</option>' +
470
+ [...opened.entries()]
471
+ .reverse()
472
+ .map(
473
+ ([thread, question]) =>
474
+ `<option value="${esc(thread)}">${esc(question.slice(0, 54) || thread)}</option>`,
475
+ )
476
+ .join('');
477
+ const selected = liveSelectedThread || '';
478
+ filter.value = [...filter.options].some((option) => option.value === selected) ? selected : '';
479
+ filter.onchange = () => {
480
+ liveSelectedThread = filter.value;
481
+ liveFilterTouched = true;
482
+ pintaActividad(true);
483
+ };
484
+ workToggle.classList.toggle('on', liveShowWork);
485
+ workToggle.setAttribute('aria-pressed', String(liveShowWork));
486
+ workToggle.textContent = liveShowWork ? 'hide work' : 'show work';
487
+ workToggle.onclick = () => {
488
+ liveShowWork = !liveShowWork;
489
+ pintaActividad(true);
490
+ };
491
+ pintaContexto(filter.value, context);
492
+
493
+ const scoped = filter.value
494
+ ? liveEvents.filter((event) => event.thread === filter.value)
495
+ : liveEvents;
496
+ const visible = scoped.filter(
497
+ (event) =>
498
+ !esPromptDeTransporte(event) &&
499
+ (liveShowWork || !esRuidoDeTrabajo(event) || event.tone === 'error'),
500
+ );
501
+ if (!visible.length) {
502
+ list.innerHTML = `<li class="liveEmpty">${
503
+ liveConnected
504
+ ? 'The bus is live. Questions, positions and moderated replies will appear here.'
505
+ : 'Start the city session. Its visible conversation will appear here as it happens.'
506
+ }</li>`;
507
+ return;
508
+ }
509
+ const wasNearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 90;
510
+ list.innerHTML = visible.map(renderActividad).join('');
511
+ if (seguir || wasNearBottom) list.scrollTop = list.scrollHeight;
512
+ }
513
+
514
+ function renderActividad(event: ActivityEvent): string {
515
+ const when = new Date(event.at);
516
+ const time = Number.isNaN(when.getTime())
517
+ ? ''
518
+ : when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
519
+ const side = event.role === 'chair' ? 'chair' : event.role === 'member' ? 'member' : 'system';
520
+ const role = rolOperativo(event.actor);
521
+ const route =
522
+ event.target && !['committee', 'seat'].includes(event.target)
523
+ ? `to ${event.target}`
524
+ : event.target === 'committee'
525
+ ? 'to the committee'
526
+ : '';
527
+ const summary = event.summary.trim();
528
+ const compact = resumenVisible(summary);
529
+ const long = compact !== summary;
530
+ const details = event.details.length
531
+ ? `<details class="liveEvidence"><summary>${event.details.length} ${
532
+ event.details.length === 1 ? 'detail' : 'details'
533
+ }</summary><ul>${event.details.map((detail) => `<li>${esc(detail)}</li>`).join('')}</ul></details>`
534
+ : '';
535
+ const full = long
536
+ ? `<details class="liveFull"><summary>read full message</summary><p>${esc(summary)}</p></details>`
537
+ : '';
538
+ return `<li class="liveTurn ${side} ${esc(event.tone)}">
539
+ <div class="liveAvatar${E?.avatars?.[event.actor] ? ' conCara' : ''}"
540
+ style="--actor-hue:${tonoActor(event.actor)}" aria-hidden="true">
541
+ ${cara(event.actor, event.role === 'chair')}
542
+ </div>
543
+ <article class="liveBubble">
544
+ <div class="liveMeta"><span class="liveActor">${esc(event.actor)}</span>
545
+ ${role ? `<span class="liveRole">${esc(role)}</span>` : ''}
546
+ ${route ? `<span>${esc(route)}</span>` : ''}<span class="liveWhen">${esc(time)}</span></div>
547
+ <div class="liveHeading">${icono(event.kind)}<h3>${esc(tituloVisible(event))}</h3></div>
548
+ <p>${esc(compact)}</p>${full}${details}
549
+ </article>
550
+ </li>`;
551
+ }
552
+
553
+ function newestConversationThread(): string {
554
+ return (
555
+ [...liveEvents]
556
+ .reverse()
557
+ .find(
558
+ (event) =>
559
+ Boolean(event.thread) &&
560
+ (event.kind === 'committee.opened' ||
561
+ (event.kind === 'conversation.user' && !esPromptDeTransporte(event))),
562
+ )?.thread || ''
563
+ );
564
+ }
565
+
566
+ function pintaContexto(thread: string, container: HTMLElement): void {
567
+ const deliberation = thread ? E?.deliberations?.find((item) => item.id === thread) : undefined;
568
+ const conversation = thread ? liveEvents.filter((event) => event.thread === thread) : [];
569
+ const opened = conversation.find((event) => event.kind === 'committee.opened');
570
+ if (!deliberation && !opened) {
571
+ container.innerHTML = '';
572
+ container.hidden = true;
573
+ return;
574
+ }
575
+ const invited =
576
+ opened?.details
577
+ .find((detail) => detail.startsWith('Invited:'))
578
+ ?.slice('Invited:'.length)
579
+ .split(',')
580
+ .map((actor) => actor.trim())
581
+ .filter(Boolean) || [];
582
+ const participants = deliberation?.participants || invited;
583
+ const received =
584
+ deliberation?.received ??
585
+ new Set(
586
+ conversation
587
+ .filter((event) => event.kind === 'committee.position.submitted')
588
+ .map((event) => event.actor),
589
+ ).size;
590
+ const total = deliberation?.total ?? participants.length;
591
+ const status = conversation.at(-1)?.phase || deliberation?.status || 'starting';
592
+ const actors = ['seat', ...participants];
593
+ container.hidden = false;
594
+ container.innerHTML = `<div class="livePeople">${actors
595
+ .map(
596
+ (actor) =>
597
+ `<span class="livePerson${E?.avatars?.[actor] ? ' conCara' : ''}" title="${esc(
598
+ actor === 'seat' ? 'seat · chair' : `${actor} · ${rolOperativo(actor) || 'member'}`,
599
+ )}" style="--actor-hue:${tonoActor(actor)}">${cara(actor, actor === 'seat')}</span>`,
600
+ )
601
+ .join('')}</div><span class="liveContextText"><b>seat moderates</b> · ${esc(
602
+ received,
603
+ )}/${esc(total)} positions · ${esc(status)}</span>`;
604
+ }
605
+
606
+ function esPromptDeTransporte(event: ActivityEvent): boolean {
607
+ if (event.kind !== 'conversation.user') return false;
608
+ const summary = event.summary.trimStart();
609
+ return (
610
+ summary.startsWith('[Agents City authenticated local bus]') ||
611
+ (summary.startsWith('<channel') && summary.slice(0, 500).includes('plugin:city:city-bus'))
612
+ );
613
+ }
614
+
615
+ function esRuidoDeTrabajo(event: ActivityEvent): boolean {
616
+ return (
617
+ event.kind === 'work.command.started' ||
618
+ event.kind === 'work.command.completed' ||
619
+ event.kind === 'runtime.turn.started' ||
620
+ event.kind === 'runtime.turn.completed' ||
621
+ event.kind === 'runtime.gateway.ready' ||
622
+ event.kind === 'runtime.session.started' ||
623
+ event.kind === 'runtime.session.ended'
624
+ );
625
+ }
626
+
627
+ function rolOperativo(actor: string): string {
628
+ if (actor === 'seat') return 'chair';
629
+ return E?.skills?.[actor]?.role || '';
630
+ }
631
+
632
+ /**
633
+ * An actor's face for the live rail: the server-drawn identicon when the city
634
+ * knows this agent, the old initials (or the chair icon) when it does not —
635
+ * an actor from another city still gets shown, just without claiming a face.
636
+ */
637
+ function cara(actor: string, esChair: boolean): string {
638
+ const uri = E?.avatars?.[actor];
639
+ if (uri && uri.startsWith('data:image/svg+xml;base64,')) {
640
+ return `<img class="liveCara" src="${esc(uri)}" alt="">`;
641
+ }
642
+ return esChair ? icono('chair') : esc(iniciales(actor));
643
+ }
644
+
645
+ function iniciales(actor: string): string {
646
+ const parts = actor.split(/[-_.\s]+/).filter(Boolean);
647
+ const initials =
648
+ parts.length > 1
649
+ ? parts
650
+ .slice(0, 2)
651
+ .map((part) => part[0])
652
+ .join('')
653
+ : actor.slice(0, 2);
654
+ return initials.toUpperCase();
655
+ }
656
+
657
+ function tonoActor(actor: string): number {
658
+ let hash = 17;
659
+ for (const character of actor) hash = (hash * 31 + character.charCodeAt(0)) % 360;
660
+ return hash;
661
+ }
662
+
663
+ function resumenVisible(summary: string): string {
664
+ if (summary.length <= 520) return summary;
665
+ const boundary = summary.slice(0, 520).lastIndexOf(' ');
666
+ return summary.slice(0, boundary > 360 ? boundary : 520).trimEnd() + '…';
667
+ }
668
+
669
+ function tituloVisible(event: ActivityEvent): string {
670
+ if (event.kind === 'conversation.user')
671
+ return event.actor === 'seat' ? 'Question' : 'Brief received';
672
+ if (event.kind === 'conversation.agent.commentary') return 'Working note';
673
+ if (event.kind === 'conversation.agent') return 'Response';
674
+ if (event.kind === 'committee.opened') return 'Question to the committee';
675
+ if (event.kind === 'committee.position.submitted') return 'Position sealed';
676
+ if (event.kind === 'committee.positions.revealed') return 'Blind round complete';
677
+ if (event.kind === 'committee.position.revealed') return 'Position';
678
+ if (event.kind === 'committee.synthesis.published') return 'Chair synthesis';
679
+ if (event.kind === 'committee.floor.requested') return 'Requests the floor';
680
+ if (event.kind === 'committee.floor.granted') return 'Floor granted';
681
+ if (event.kind === 'committee.floor.denied') return 'Floor denied';
682
+ if (event.kind === 'committee.floor.spoke') return 'Intervention';
683
+ if (event.kind === 'committee.decision.recorded') return 'Decision';
684
+ if (event.kind.startsWith('committee.verification.')) return 'Verification';
685
+ if (event.kind === 'committee.closed') return 'Committee closed';
686
+ return event.title;
687
+ }
688
+
689
+ function icono(kind: string): string {
690
+ if (kind === 'chair') {
691
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 17h16l-1.4-9-4.1 3L12 5l-2.5 6-4.1-3L4 17Zm1 2h14"/></svg>';
692
+ }
693
+ if (kind.includes('question') || kind === 'committee.opened' || kind === 'conversation.user') {
694
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5h14v10H9l-4 4V5Zm5 4a2 2 0 1 1 3.4 1.4c-.9.6-1.4 1-1.4 2.1M12 14.7v.1"/></svg>';
695
+ }
696
+ if (kind.includes('decision') || kind.includes('verification') || kind === 'committee.closed') {
697
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 4 4L19 6"/></svg>';
698
+ }
699
+ if (kind.includes('floor')) {
700
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 11V5a1.5 1.5 0 0 1 3 0v5-7a1.5 1.5 0 0 1 3 0v7-5a1.5 1.5 0 0 1 3 0v7l1-2a1.5 1.5 0 0 1 2.6 1.5L17 18a4 4 0 0 1-3.5 2H10a5 5 0 0 1-5-5v-3.5a1.5 1.5 0 0 1 3 0V13"/></svg>';
701
+ }
702
+ if (kind.includes('command') || kind.includes('work.')) {
703
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 8 4 4-4 4m6 0h6"/></svg>';
704
+ }
705
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6h14v12H5zM8 10h8m-8 4h5"/></svg>';
706
+ }
707
+
708
+ function rail(): void {
709
+ q('#rail').innerHTML = SECCIONES.map(([id, et]) => {
710
+ const n =
711
+ id === 'gente'
712
+ ? String(Object.keys(E.skills).length)
713
+ : id === 'committee'
714
+ ? String(E.deliberations.length)
715
+ : id === 'red'
716
+ ? String(E.roads.length)
717
+ : id === 'barrios'
718
+ ? String(E.parcelas.length)
719
+ : '';
720
+ return `<li class="${id === SECCION ? 'aqui' : ''}" data-s="${id}">
721
+ <span>${et}</span>${n ? `<span class="n">${n}</span>` : ''}</li>`;
722
+ }).join('');
723
+ todos<HTMLElement>('#rail li').forEach((li) => {
724
+ li.onclick = () => {
725
+ SECCION = li.dataset.s ?? 'mapa';
726
+ pinta();
727
+ };
728
+ });
729
+ q('#dondeDatos').textContent = corto(E.datos);
730
+ const cs = q<HTMLElement>('#ciudades');
731
+ if (E.ciudades.length > 1) {
732
+ cs.innerHTML = `<select id="cambiaCiudad" title="which city this hall manages">
733
+ ${E.ciudades
734
+ .map(
735
+ (c) => `<option value="${esc(c.ruta)}" ${c.actual ? 'selected' : ''}>
736
+ ${esc(c.nombre)}</option>`,
737
+ )
738
+ .join('')}</select>`;
739
+ q<HTMLSelectElement>('#cambiaCiudad').onchange = (ev) => {
740
+ const ruta = (ev.target as HTMLSelectElement).value;
741
+ location.href = '/?PASE=' + encodeURIComponent(PASE) + '&city=' + encodeURIComponent(ruta);
742
+ };
743
+ } else {
744
+ cs.innerHTML = '';
745
+ }
746
+ const nueva = q<HTMLAnchorElement>('#alWizard');
747
+ nueva.onclick = async (ev) => {
748
+ ev.preventDefault();
749
+ const name = window.prompt('Name of the new city');
750
+ if (!name?.trim()) return;
751
+ const r = await api<Respuesta>('/api/ciudades', {
752
+ method: 'POST',
753
+ body: JSON.stringify({ name }),
754
+ });
755
+ if (r.error || !r.city) return toast(r.error ?? 'Could not create the city', true);
756
+ location.href = '/?PASE=' + encodeURIComponent(PASE) + '&city=' + encodeURIComponent(r.city);
757
+ };
758
+ }
759
+
760
+ function pinta(): void {
761
+ rail();
762
+ q<HTMLElement>('.cuerpo').style.padding = '';
763
+ q<HTMLElement>('#lienzo').style.maxWidth = '';
764
+ VISTAS[SECCION]();
765
+ q<HTMLElement>('.cuerpo').scrollTop = 0;
766
+ }
767
+
768
+ const VISTAS: Record<string, () => void> = {};
769
+
770
+ // ── overview ─────────────────────────────────────────────────────────────────
771
+ VISTAS.resumen = () => {
772
+ const mia = E.tarjetas.find((t) => t.user === E.yo);
773
+ const sinUnidad = E.parcelas.filter((p) => p.unidad === 'none' || p.unidad === 'mine').length;
774
+ const tareas = [
775
+ {
776
+ ok: !!mia,
777
+ txt: mia
778
+ ? `Your card exists — you are <b>${esc(mia.agent)}</b>`
779
+ : 'Take your seat: role, folders, one goal',
780
+ ir: 'puesto',
781
+ },
782
+ {
783
+ ok: !!(mia && mia.goals_defined),
784
+ txt:
785
+ mia && mia.goals_defined
786
+ ? `Your goal: <b>${esc(mia.objetivo ? mia.objetivo.title : '')}</b>`
787
+ : 'Set <b>one goal</b>, with the command that measures it',
788
+ ir: 'puesto',
789
+ },
790
+ {
791
+ ok: E.parcelas.length > 0 && sinUnidad === 0,
792
+ txt:
793
+ E.parcelas.length === 0
794
+ ? 'No houses yet — pick your folders and they appear'
795
+ : sinUnidad
796
+ ? `<b>${sinUnidad}</b> of ${E.parcelas.length} houses sit in no real district — assign them`
797
+ : 'Every house has its district',
798
+ ir: 'barrios',
799
+ },
800
+ ];
801
+ const sesionArriba = E.tmux.includes(E.sesion);
802
+ q('#lienzo').innerHTML = `
803
+ <div><span class="sub">your city</span>
804
+ <h1 style="margin-top:6px">${esc(E.city_name)}</h1>
805
+ <p class="de mono" style="margin-top:5px">${esc(E.address)}</p>
806
+ <p class="prosa" style="margin-top:8px">Domain: <b>${esc(E.domain)}</b>. Growth is
807
+ counted with <code class="mono">${esc(E.grow || 'nothing yet')}</code>.</p></div>
808
+ <div class="cifras">
809
+ <div class="cifra"><b>${Object.keys(E.skills).length}</b><span>repo agents</span></div>
810
+ <div class="cifra"><b>${E.parcelas.length}</b><span>houses</span></div>
811
+ <div class="cifra"><b>${E.roads.length}</b><span>roads</span></div>
812
+ <div class="cifra"><b>${E.deliberations.length}</b><span>committee acts</span></div>
813
+ <div class="cifra"><b>${Object.values(E.skills).reduce((n, a) => n + a.skills.length, 0)}</b><span>skills recognised</span></div>
814
+ </div>
815
+ <div class="luces">
816
+ <span class="luz ${E.tarjetas.length ? 'on' : ''}">data repo</span>
817
+ <span class="luz ${E.gh ? 'on' : 'neutra'}">github${E.gh ? '' : ' — optional'}</span>
818
+ <span class="luz ${E.plugin ? 'on' : ''}">plugin installed</span>
819
+ <span class="luz ${sesionArriba ? 'on' : 'neutra'}">tmux session${sesionArriba ? ' up' : ''}</span>
820
+ </div>
821
+ <div><span class="sub">what is left</span>
822
+ <div class="tareas" style="margin-top:9px">${tareas
823
+ .map(
824
+ (t) => `
825
+ <div class="tarea ${t.ok ? 'hecha' : ''}">${t.txt}
826
+ ${t.ok ? '' : `<button class="bt mini ir" data-ir="${t.ir}">go</button>`}</div>`,
827
+ )
828
+ .join('')}
829
+ </div></div>
830
+ <div><span class="sub">work</span>
831
+ <div style="display:flex;flex-direction:column;gap:9px;margin-top:9px">
832
+ <div class="orden"><span class="et2">your day</span>
833
+ <code>${
834
+ sesionArriba
835
+ ? 'tmux attach -t ' + esc(E.sesion)
836
+ : 'one window per folder, an agent in each'
837
+ }</code>
838
+ ${
839
+ sesionArriba
840
+ ? `<button class="bt mini" data-copia="tmux attach -t ${esc(E.sesion)}">copy</button>`
841
+ : '<button class="bt mini ppal" id="abreSesion">open my session</button>'
842
+ }
843
+ </div>
844
+ <div class="orden"><span class="et2">the map</span>
845
+ <code>${E.mapa ? esc(E.mapa) : 'not drawn yet'}</code>
846
+ <button class="bt mini ${E.mapa ? '' : 'ppal'}" data-ir="mapa">${E.mapa ? 'open' : 'draw it'}</button></div>
847
+ </div></div>`;
848
+ enlaza();
849
+ const b = document.querySelector<HTMLButtonElement>('#abreSesion');
850
+ if (b)
851
+ b.onclick = async () => {
852
+ b.disabled = true;
853
+ b.textContent = 'building…';
854
+ const r = await api<Respuesta>('/api/sesion', {
855
+ method: 'POST',
856
+ body: JSON.stringify({ user: E.yo }),
857
+ });
858
+ await espera(1500);
859
+ await refresca();
860
+ toast(
861
+ r.ok && r.attach ? `Session built — attach with: ${r.attach}` : 'Could not build it',
862
+ !r.ok,
863
+ );
864
+ };
865
+ };
866
+
867
+ // ── the map ──────────────────────────────────────────────────────────────────
868
+ VISTAS.mapa = () => {
869
+ if (E.mapa) {
870
+ // One web: the map lives inside the hall. It is its own server (a Worker,
871
+ // independently deployable) so it arrives by iframe, full bleed. Embed mode
872
+ // hides the map's standalone rails: the Hall already owns both sidebars.
873
+ q<HTMLElement>('.cuerpo').style.padding = '0';
874
+ q<HTMLElement>('#lienzo').style.maxWidth = 'none';
875
+ const mapUrl = new URL(E.mapa, location.href);
876
+ mapUrl.searchParams.set('embed', '1');
877
+ mapUrl.searchParams.set('parent_origin', location.origin);
878
+ q('#lienzo').innerHTML =
879
+ `<iframe id="cityMapFrame" src="${esc(mapUrl.toString())}" title="the map"
880
+ allow="fullscreen" allowfullscreen
881
+ style="width:100%;height:calc(100vh - 2px);border:0;display:block"></iframe>`;
882
+ const frame = q<HTMLIFrameElement>('#cityMapFrame');
883
+ frame.onload = () => {
884
+ frame.dataset.ready = '1';
885
+ // What only the Hall knows and the map should draw: the city's roads,
886
+ // which become its gates. The standalone map never gets this and never
887
+ // invents a gate.
888
+ const origin = new URL(frame.src, location.href).origin;
889
+ frame.contentWindow?.postMessage(
890
+ {
891
+ protocol: MAP_ACTIVITY_PROTOCOL,
892
+ type: 'map.config',
893
+ roads: E.roads.map((r) => ({ name: r.name, address: r.address })),
894
+ agents: (E.agents ?? []).map((a) => ({ name: a.name, kind: a.kind })),
895
+ avatars: E.avatars ?? {},
896
+ },
897
+ origin,
898
+ );
899
+ // The map loads after the theme was decided, so tell it now as well —
900
+ // otherwise a light Hall frames a night map until the next toggle.
901
+ frame.contentWindow?.postMessage(
902
+ { type: 'agents-city-map-theme/1', theme: temaActual() },
903
+ origin,
904
+ );
905
+ if (pendingMapActivity) {
906
+ const event = pendingMapActivity;
907
+ pendingMapActivity = null;
908
+ enviaActividadAlMapa(event);
909
+ }
910
+ };
911
+ return;
912
+ }
913
+ q('#lienzo').innerHTML = `<div><span class="sub">the map</span>
914
+ <h1 style="margin-top:6px">Not drawn yet</h1>
915
+ <p class="prosa" style="margin-top:8px">The map can be served independently.
916
+ Locally the hall runs it for you: first time bakes the front end
917
+ and seeds the local database, which takes about a minute.</p></div>
918
+ <div><button class="bt ppal" id="arrancaMapa">Draw my city</button></div>
919
+ <p class="cargando" id="mapaEspera" style="display:none">baking the map — first time takes a minute</p>`;
920
+ q<HTMLButtonElement>('#arrancaMapa').onclick = async () => {
921
+ q<HTMLButtonElement>('#arrancaMapa').disabled = true;
922
+ q<HTMLElement>('#mapaEspera').style.display = '';
923
+ await api<Respuesta>('/api/mapa', { method: 'POST', body: '{}' });
924
+ for (let i = 0; i < 60; i++) {
925
+ await espera(3000);
926
+ E = await api<Estado>('/api/estado');
927
+ if (E.mapa) {
928
+ pinta();
929
+ return;
930
+ }
931
+ }
932
+ toast('It did not come up — run ./bin/city by hand and look at its output', true);
933
+ pinta();
934
+ };
935
+ };
936
+
937
+ // ── my seat ──────────────────────────────────────────────────────────────────
938
+ VISTAS.puesto = () => {
939
+ void puesto();
940
+ };
941
+
942
+ async function puesto(): Promise<void> {
943
+ const mia = E.tarjetas.find((t) => t.user === E.yo);
944
+ q('#lienzo').innerHTML = `<div><span class="sub">my seat</span>
945
+ <h1 style="margin-top:6px">${esc(E.yo)}</h1>
946
+ <p class="prosa" style="margin-top:8px">This is your chair: the work domain, your
947
+ role inside it, and one goal. The seat stays the boss even when its
948
+ professional role is blank. Saving writes your card —
949
+ <code class="mono">${esc(corto(E.datos))}/${esc(E.yo)}.md</code> — the same file
950
+ every other door writes, and it never touches your roster.</p></div>
951
+ <div><span class="sub">work domain</span><div class="rolejilla" id="domains" style="margin-top:9px">
952
+ <p class="cargando">reading the domain packs</p></div></div>
953
+ <div><span class="sub">role</span><div class="rolejilla" id="roles" style="margin-top:9px">
954
+ <p class="cargando">reading the role files</p></div></div>
955
+ <div><span class="sub">the agents</span>
956
+ <p class="prosa" style="margin-top:8px">Who works in this city — and what each
957
+ one works on — lives in <b>Agents &amp; skills</b>, where an agent is asked for
958
+ whole: its kind, its role, its repositories and document folders, its engine
959
+ and its skills. One place, so the terminal and this page cannot disagree.
960
+ <button class="bt" type="button" data-ir="gente" style="margin-left:8px">Open the roster</button></p></div>
961
+ <div><span class="sub">one goal — optional</span><div class="campos" id="meta" style="margin-top:9px"></div></div>
962
+ <div style="display:flex;gap:10px;align-items:center">
963
+ <button class="bt ppal" id="guardaPuesto">Save my seat</button>
964
+ <span class="cargando" id="puestoEstado" style="display:none">writing</span></div>`;
965
+
966
+ // Domain first, then only the roles relevant inside it. Both are copied into
967
+ // the city as editable Markdown; neither changes skills in a referenced repo.
968
+ const domainsR = (await api<{ domains: DomainInfo[] }>('/api/domains')).domains;
969
+ let domain = E.domain || E.kind || 'software';
970
+ let rol = mia ? mia.role : '';
971
+ let rolesR: RolInfo[] = [];
972
+ const pintaRoles = (): void => {
973
+ q('#roles').innerHTML = rolesR
974
+ .map(
975
+ (r) => `
976
+ <button class="rol ${r.id === rol ? 'on' : ''}" data-r="${esc(r.id)}">
977
+ <h3>${esc(r.name)}</h3><span class="de">${esc(r.id)} · ${esc(r.trade.toLowerCase())}</span>
978
+ <p>${esc(r.summary)}</p></button>`,
979
+ )
980
+ .join('');
981
+ todos<HTMLButtonElement>('#roles .rol').forEach((b) => {
982
+ b.onclick = () => {
983
+ rol = b.dataset.r ?? '';
984
+ pintaRoles();
985
+ };
986
+ });
987
+ };
988
+ const cargaRoles = async (): Promise<void> => {
989
+ rolesR = (await api<{ roles: RolInfo[] }>('/api/roles?domain=' + encodeURIComponent(domain)))
990
+ .roles;
991
+ if (!rolesR.some((r) => r.id === rol)) rol = '';
992
+ pintaRoles();
993
+ };
994
+ const pintaDominios = (): void => {
995
+ q('#domains').innerHTML = domainsR
996
+ .map(
997
+ (d) => `
998
+ <button class="rol ${d.id === domain ? 'on' : ''}" data-d="${esc(d.id)}">
999
+ <h3>${esc(d.name)}</h3><span class="de">${esc(d.id)}</span>
1000
+ <p>${esc(d.summary)}</p></button>`,
1001
+ )
1002
+ .join('');
1003
+ todos<HTMLButtonElement>('#domains .rol').forEach((b) => {
1004
+ b.onclick = () => {
1005
+ domain = b.dataset.d ?? 'software';
1006
+ pintaDominios();
1007
+ void cargaRoles();
1008
+ };
1009
+ });
1010
+ };
1011
+ pintaDominios();
1012
+ await cargaRoles();
1013
+
1014
+ // The roster is not asked for here any more: it lives in one place,
1015
+ // the agents view, so this page and the terminal cannot write different
1016
+ // cities. What this page owns is the chair — domain, role, goal.
1017
+
1018
+ // goal
1019
+ const o: Partial<Objetivo> = (mia && mia.objetivo) || {};
1020
+ q('#meta').innerHTML = `
1021
+ <div class="campo"><label>The goal, in one line</label>
1022
+ <input type="text" id="g_title" value="${esc(o.title)}" placeholder="Concrete enough to argue with — empty skips it">
1023
+ </div>
1024
+ <div class="dos">
1025
+ <div class="campo"><label>How it is measured</label>
1026
+ <input type="text" id="g_signal" value="${esc(o.signal)}"></div>
1027
+ <div class="campo"><label>The command that returns it, if a command can</label>
1028
+ <input type="text" class="mono" id="g_command" value="${esc(o.command)}"
1029
+ placeholder="empty for a qualitative goal"></div>
1030
+ </div>
1031
+ <div class="campo"><label>…or who judges it, and how often</label>
1032
+ <input type="text" id="g_manual" value="${esc(o.manual)}"
1033
+ placeholder="in prose: the architect reads the AGENTS.md files on Fridays">
1034
+ <p class="pista">A goal judged by a person is a real goal — plenty of quality
1035
+ is prose, not a number. Used when the command is empty.</p></div>
1036
+ <div class="dos">
1037
+ <div class="campo"><label>What it returns today</label>
1038
+ <input type="text" class="mono" id="g_baseline" value="${esc(o.baseline)}"></div>
1039
+ <div class="campo"><label>Where it has to get to</label>
1040
+ <input type="text" class="mono" id="g_target" value="${esc(o.target)}"></div>
1041
+ </div>
1042
+ <div class="campo" style="max-width:240px"><label>By when</label>
1043
+ <input type="text" id="g_by" value="${esc(o.by ?? 'this quarter')}"></div>`;
1044
+
1045
+ const val = (id: string): string => q<HTMLInputElement>(id).value.trim();
1046
+ q<HTMLButtonElement>('#guardaPuesto').onclick = async () => {
1047
+ if (!rol) {
1048
+ toast('Pick a role — it is the name your seat answers to', true);
1049
+ return;
1050
+ }
1051
+ q<HTMLElement>('#puestoEstado').style.display = '';
1052
+ const r = await api<Respuesta>('/api/ficha', {
1053
+ method: 'POST',
1054
+ body: JSON.stringify({
1055
+ user: E.yo,
1056
+ domain,
1057
+ role: rol,
1058
+ objetivo: {
1059
+ title: val('#g_title'),
1060
+ signal: val('#g_signal'),
1061
+ command: val('#g_command'),
1062
+ manual: val('#g_manual'),
1063
+ baseline: val('#g_baseline'),
1064
+ target: val('#g_target'),
1065
+ by: val('#g_by') || 'this quarter',
1066
+ },
1067
+ }),
1068
+ });
1069
+ await refresca();
1070
+ toast(
1071
+ r.ok ? `Card written — you are ${r.user ?? E.yo}` : (r.error ?? 'It could not write'),
1072
+ !r.ok,
1073
+ );
1074
+ };
1075
+ }
1076
+
1077
+ // ── districts & houses ───────────────────────────────────────────────────────
1078
+ VISTAS.barrios = () => {
1079
+ // Local working copies; nothing touches disk until Save.
1080
+ const unidades: Unidad[] = E.unidades.map((u) => ({ ...u }));
1081
+ const lab = new Set<string>(E.lab);
1082
+ interface Fila {
1083
+ ruta: string;
1084
+ unidad: string;
1085
+ nombre: string;
1086
+ }
1087
+ const porRepo: Record<string, Fila[]> = {};
1088
+ for (const p of E.parcelas) {
1089
+ (porRepo[p.repo] = porRepo[p.repo] ?? []).push({
1090
+ ruta: p.ruta,
1091
+ unidad: p.unidad,
1092
+ nombre: p.nombre,
1093
+ });
1094
+ }
1095
+ const slug = (s: string): string =>
1096
+ s
1097
+ .toLowerCase()
1098
+ .replace(/[^a-z0-9]+/g, '-')
1099
+ .replace(/^-|-$/g, '')
1100
+ .slice(0, 20);
1101
+
1102
+ const render = (): void => {
1103
+ const idsValidos = new Set([...unidades.map((u) => u.id), 'none']);
1104
+ q('#lienzo').innerHTML = `<div><span class="sub">districts & houses</span>
1105
+ <h1 style="margin-top:6px">The modelling no tool can do for you</h1>
1106
+ <p class="prosa" style="margin-top:8px">A house is <b>not a repo</b> — it is a
1107
+ parcel, a slice of one serving a single business unit. Split the interesting
1108
+ repos, give every house its district, and the map can say
1109
+ “this change touches banking” instead of “this change touches src/lib”.</p></div>
1110
+
1111
+ <div><span class="sub">districts</span>
1112
+ <div class="tabla" id="unidades" style="margin-top:9px"></div>
1113
+ <div style="display:flex;gap:9px;margin-top:9px">
1114
+ <input type="text" id="nuevaU" placeholder="another district…" style="flex:1">
1115
+ <button class="bt" id="masU">Add</button></div></div>
1116
+
1117
+ <div><span class="sub">houses</span><div id="parcelas" style="margin-top:9px"></div></div>
1118
+
1119
+ <div style="display:flex;gap:10px;align-items:center">
1120
+ <button class="bt ppal" id="guardaBarrios">Save districts & houses</button>
1121
+ <span style="font-size:12px;color:var(--tinta3);font-style:italic">
1122
+ writes units.yml and parcels.yml — reseed the map to see it drawn</span></div>`;
1123
+
1124
+ q('#unidades').innerHTML =
1125
+ unidades
1126
+ .map(
1127
+ (u, i) => `
1128
+ <div class="tr">
1129
+ <input type="text" value="${esc(u.name)}" data-i="${i}" style="flex:1">
1130
+ <span class="id">${esc(u.id)}</span>
1131
+ <span class="pips" data-i="${i}">${E.paleta
1132
+ .map(
1133
+ (p) => `
1134
+ <button class="pip ${p.hex === u.color ? 'on' : ''}" data-c="${p.hex}"
1135
+ style="background:#${p.hex}" title="${esc(p.nombre)}"></button>`,
1136
+ )
1137
+ .join('')}</span>
1138
+ <button class="x" data-x="${i}">×</button></div>`,
1139
+ )
1140
+ .join('') ||
1141
+ '<div class="tr" style="color:var(--tinta3);font-size:12.5px">No districts yet — everything sits in “no unit”.</div>';
1142
+
1143
+ q('#parcelas').innerHTML =
1144
+ Object.keys(porRepo)
1145
+ .sort()
1146
+ .map(
1147
+ (repo) => `
1148
+ <div class="repoBloque" data-repo="${esc(repo)}">
1149
+ <div class="repoCab"><span>${esc(repo)}</span>
1150
+ <button class="bt mini" data-split="${esc(repo)}">split</button>
1151
+ <label class="lab"><input type="checkbox" data-lab="${esc(repo)}"
1152
+ ${lab.has(repo) ? 'checked' : ''}> lab</label></div>
1153
+ ${(porRepo[repo] ?? [])
1154
+ .map(
1155
+ (f, j) => `
1156
+ <div class="parcela">
1157
+ <input type="text" value="${esc(f.nombre)}" data-k="nombre" data-r="${esc(repo)}" data-j="${j}" title="name on the map">
1158
+ <input type="text" class="mono" value="${esc(f.ruta)}" data-k="ruta" data-r="${esc(repo)}" data-j="${j}"
1159
+ placeholder="glob inside the repo — empty = the whole repo">
1160
+ <select data-k="unidad" data-r="${esc(repo)}" data-j="${j}">
1161
+ ${[...unidades.map((u) => u.id), 'none']
1162
+ .map(
1163
+ (id) => `
1164
+ <option value="${esc(id)}" ${(idsValidos.has(f.unidad) ? f.unidad : 'none') === id ? 'selected' : ''}>${esc(id)}</option>`,
1165
+ )
1166
+ .join('')}
1167
+ </select>
1168
+ ${
1169
+ (porRepo[repo] ?? []).length > 1
1170
+ ? `<button class="x" data-quita="${esc(repo)}" data-j="${j}">×</button>`
1171
+ : '<span></span>'
1172
+ }
1173
+ </div>`,
1174
+ )
1175
+ .join('')}
1176
+ </div>`,
1177
+ )
1178
+ .join('') ||
1179
+ '<p class="prosa">No houses yet. Pick your folders in <b>My seat</b> and each becomes one.</p>';
1180
+
1181
+ // wiring
1182
+ todos<HTMLInputElement>('#unidades input[data-i]').forEach((inp) => {
1183
+ inp.oninput = () => {
1184
+ const u = unidades[Number(inp.dataset.i)];
1185
+ if (!u) return;
1186
+ u.name = inp.value;
1187
+ u.id = slug(inp.value) || u.id;
1188
+ const idEl = inp.closest('.tr')?.querySelector('.id');
1189
+ if (idEl) idEl.textContent = u.id;
1190
+ };
1191
+ });
1192
+ todos<HTMLButtonElement>('#unidades .pip').forEach((b) => {
1193
+ b.onclick = () => {
1194
+ const grupo = b.closest<HTMLElement>('.pips');
1195
+ const u = unidades[Number(grupo?.dataset.i)];
1196
+ if (u) {
1197
+ u.color = b.dataset.c ?? u.color;
1198
+ render();
1199
+ }
1200
+ };
1201
+ });
1202
+ todos<HTMLButtonElement>('#unidades .x[data-x]').forEach((b) => {
1203
+ b.onclick = () => {
1204
+ unidades.splice(Number(b.dataset.x), 1);
1205
+ render();
1206
+ };
1207
+ });
1208
+ q<HTMLButtonElement>('#masU').onclick = () => {
1209
+ const n = q<HTMLInputElement>('#nuevaU').value.trim();
1210
+ if (!n) return;
1211
+ const usados = unidades.map((u) => u.color);
1212
+ const libre = E.paleta.find((p) => !usados.includes(p.hex)) ?? E.paleta[0];
1213
+ unidades.push({ id: slug(n), name: n, color: libre ? libre.hex : 'c8b48a' });
1214
+ render();
1215
+ };
1216
+ q<HTMLInputElement>('#nuevaU').onkeydown = (e) => {
1217
+ if (e.key === 'Enter') q<HTMLButtonElement>('#masU').click();
1218
+ };
1219
+
1220
+ todos<HTMLInputElement | HTMLSelectElement>('#parcelas [data-k]').forEach((el) => {
1221
+ el.onchange = () => {
1222
+ const fila = porRepo[el.dataset.r ?? '']?.[Number(el.dataset.j)];
1223
+ if (!fila) return;
1224
+ const k = el.dataset.k as keyof Fila;
1225
+ fila[k] = el.value;
1226
+ };
1227
+ });
1228
+ todos<HTMLButtonElement>('#parcelas [data-split]').forEach((b) => {
1229
+ b.onclick = () => {
1230
+ const r = b.dataset.split ?? '';
1231
+ (porRepo[r] = porRepo[r] ?? []).push({
1232
+ ruta: '',
1233
+ unidad: 'none',
1234
+ nombre: `${r} · new slice`,
1235
+ });
1236
+ render();
1237
+ };
1238
+ });
1239
+ todos<HTMLButtonElement>('#parcelas [data-quita]').forEach((b) => {
1240
+ b.onclick = () => {
1241
+ porRepo[b.dataset.quita ?? '']?.splice(Number(b.dataset.j), 1);
1242
+ render();
1243
+ };
1244
+ });
1245
+ todos<HTMLInputElement>('#parcelas [data-lab]').forEach((c) => {
1246
+ c.onchange = () => {
1247
+ const r = c.dataset.lab ?? '';
1248
+ if (c.checked) lab.add(r);
1249
+ else lab.delete(r);
1250
+ };
1251
+ });
1252
+
1253
+ q<HTMLButtonElement>('#guardaBarrios').onclick = async () => {
1254
+ const r1 = await api<Respuesta>('/api/unidades', {
1255
+ method: 'POST',
1256
+ body: JSON.stringify({ unidades }),
1257
+ });
1258
+ const r2 = await api<Respuesta>('/api/parcelas', {
1259
+ method: 'POST',
1260
+ body: JSON.stringify({ repos: porRepo, lab: [...lab] }),
1261
+ });
1262
+ await refresca();
1263
+ const mal = r1.error ?? r2.error;
1264
+ toast(mal ?? 'Districts and houses written', !!mal);
1265
+ };
1266
+ };
1267
+ render();
1268
+ };
1269
+
1270
+ // ── roads ────────────────────────────────────────────────────────────────────
1271
+ VISTAS.red = () => {
1272
+ const conectadas = new Set(E.roads.map((r) => r.id));
1273
+ const locales = E.ciudades.filter((c) => !c.actual && c.id && !conectadas.has(c.id));
1274
+ const invitation = JSON.stringify(E.invitation);
1275
+ q('#lienzo').innerHTML = `<div><span class="sub">roads</span>
1276
+ <h1 style="margin-top:6px">Cities this one may reach</h1>
1277
+ <p class="prosa" style="margin-top:8px">A road joins city seats. It may stay on
1278
+ this machine or continue over the remote bus; the city sees one explicit
1279
+ connection either way.</p></div>
1280
+ <div class="gente">${
1281
+ E.roads
1282
+ .map(
1283
+ (r) => `<div class="persona"><h3>${esc(r.name)}</h3>
1284
+ <span class="ag">${esc(r.address)}</span>
1285
+ <span class="rp">${r.local ? 'local road' : 'remote road'}</span>
1286
+ <button class="bt mini" data-road-close="${esc(r.id)}">close</button></div>`,
1287
+ )
1288
+ .join('') || '<p class="prosa">No roads yet. This city is isolated on purpose.</p>'
1289
+ }</div>
1290
+ <div><span class="sub">other cities on this machine</span>
1291
+ <div class="gente" style="margin-top:9px">${
1292
+ locales
1293
+ .map(
1294
+ (c) => `<div class="persona"><h3>${esc(c.nombre)}</h3>
1295
+ <span class="ag">${esc(c.slug ?? c.ruta)}</span>
1296
+ <button class="bt mini ppal" data-road-open="${esc(c.id)}">open road</button></div>`,
1297
+ )
1298
+ .join('') || '<p class="prosa">No unconnected local cities.</p>'
1299
+ }</div></div>
1300
+ <div class="orden"><span class="et2">remote invitation · public, no token</span>
1301
+ <code>${esc(invitation)}</code>
1302
+ <button class="bt mini" data-copia="${esc(invitation)}">copy</button>
1303
+ </div>`;
1304
+ enlaza();
1305
+ todos<HTMLButtonElement>('[data-road-open]').forEach((b) => {
1306
+ b.onclick = async () => {
1307
+ const r = await api<Respuesta>('/api/roads', {
1308
+ method: 'POST',
1309
+ body: JSON.stringify({ action: 'connect', target: b.dataset.roadOpen }),
1310
+ });
1311
+ if (r.error) return toast(r.error, true);
1312
+ await refresca();
1313
+ toast('Road open at both local cities');
1314
+ };
1315
+ });
1316
+ todos<HTMLButtonElement>('[data-road-close]').forEach((b) => {
1317
+ b.onclick = async () => {
1318
+ const r = await api<Respuesta>('/api/roads', {
1319
+ method: 'POST',
1320
+ body: JSON.stringify({ action: 'disconnect', target: b.dataset.roadClose }),
1321
+ });
1322
+ if (r.error) return toast(r.error, true);
1323
+ await refresca();
1324
+ toast('Road closed');
1325
+ };
1326
+ });
1327
+ };
1328
+
1329
+ // ── committee acts ───────────────────────────────────────────────────────────
1330
+ VISTAS.committee = () => {
1331
+ q('#lienzo').innerHTML = `<div><span class="sub">committee</span>
1332
+ <h1 style="margin-top:6px">Decisions with a visible chain of custody</h1>
1333
+ <p class="prosa" style="margin-top:8px">The seat selects relevant repo agents,
1334
+ gathers isolated positions, controls the floor, decides and assigns an
1335
+ independent verification. This view is read-only; the vendor-neutral CLI
1336
+ drives the protocol.</p></div>
1337
+ <div class="orden"><span class="et2">start</span>
1338
+ <code>agents-city committee schema open</code>
1339
+ <button class="bt mini" data-copia="agents-city committee schema open">copy</button>
1340
+ </div>
1341
+ <div class="gente">${
1342
+ E.deliberations
1343
+ .map(
1344
+ (act) => `<div class="persona">
1345
+ <h3>${esc(act.question)}</h3>
1346
+ <span class="ag">${esc(act.status)} · revision ${act.revision} ·
1347
+ ${act.received}/${act.total} positions</span>
1348
+ <span class="rp">${esc(act.decision || act.desired_outcome)}</span>
1349
+ <span class="de">${esc(act.participants.join(', ') || 'no participants')}
1350
+ ${act.contributors.length ? ` · decisive ${esc(act.contributors.join(', '))}` : ''}
1351
+ ${act.verifier ? ` · verifier ${esc(act.verifier)} ${esc(act.verification)}` : ''}</span>
1352
+ <code class="mono">${esc(corto(act.act))}</code>
1353
+ </div>`,
1354
+ )
1355
+ .join('') ||
1356
+ '<p class="prosa">No committee acts yet. Open one only when the seat needs specialised evidence.</p>'
1357
+ }</div>`;
1358
+ enlaza();
1359
+ };
1360
+
1361
+ // ── agents and skills ────────────────────────────────────────────────────────
1362
+ /** The engine's honest power reading. An alias maps to its tier; empty means
1363
+ * the owner's Claude default — unknown here, so the bar says "default" rather
1364
+ * than inventing a number. */
1365
+ function nivelDeMotor(model: string): { ancho: number; texto: string; defecto: boolean } {
1366
+ const alias = model.toLowerCase();
1367
+ if (!alias) return { ancho: 0.5, texto: 'default', defecto: true };
1368
+ for (const [clave, ancho] of [
1369
+ ['fable', 1],
1370
+ ['opus', 0.8],
1371
+ ['sonnet', 0.55],
1372
+ ['haiku', 0.35],
1373
+ ] as const) {
1374
+ if (alias.includes(clave)) return { ancho, texto: alias, defecto: false };
1375
+ }
1376
+ return { ancho: 0.6, texto: alias, defecto: false };
1377
+ }
1378
+
1379
+ const NIVEL_ESFUERZO: Record<string, number> = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 };
1380
+ const MODELOS = ['haiku', 'sonnet', 'opus', 'fable'];
1381
+
1382
+ /** `<option>`s from known values plus the current one when it is off the list:
1383
+ * a card key must never be held hostage by a curated dropdown. */
1384
+ function opciones(valores: string[], actual: string): string {
1385
+ return valores
1386
+ .concat(actual && !valores.includes(actual) ? [actual] : [])
1387
+ .map(
1388
+ (v) =>
1389
+ `<option value="${esc(v)}"${v === actual ? ' selected' : ''}>${esc(v || 'default')}</option>`,
1390
+ )
1391
+ .join('');
1392
+ }
1393
+
1394
+ /** The one sheet-avatar guard: a data URI or nothing. */
1395
+ function caraRPG(uri?: string): string {
1396
+ return uri?.startsWith('data:image/svg+xml;base64,')
1397
+ ? `<img class="rpgCara" src="${esc(uri)}" alt="">`
1398
+ : '';
1399
+ }
1400
+
1401
+ function barra(ancho: number, clase = ''): string {
1402
+ const pct = Math.round(Math.max(0, Math.min(1, ancho)) * 100);
1403
+ return `<span class="barra${clase ? ' ' + clase : ''}"><span class="llena" style="width:${pct}%"></span></span>`;
1404
+ }
1405
+
1406
+ VISTAS.gente = () => {
1407
+ const mia = E.tarjetas.find((t) => t.user === E.yo);
1408
+ const agentes = E.agents ?? [];
1409
+
1410
+ const fichaDe = (a: FichaAgente) => {
1411
+ const motor = nivelDeMotor(a.model);
1412
+ const esfuerzo = NIVEL_ESFUERZO[a.effort] ?? 0;
1413
+ const g = a.growth;
1414
+ const crecido = Math.min(1, Math.log2(g.floors + 1) / 8);
1415
+ const skills = E.skills[a.name]?.skills ?? [];
1416
+ const esClaude = a.runtime === 'claude';
1417
+ // "custom…" keeps the curated lineup from ever holding the card hostage:
1418
+ // the alias is resolved by the CLI at launch, not by this list.
1419
+ const opcionesModelo =
1420
+ opciones([''].concat(MODELOS), a.model) + '<option value="__otro__">custom…</option>';
1421
+ const opcionesEsfuerzo = opciones([''].concat(Object.keys(NIVEL_ESFUERZO)), a.effort);
1422
+ const opcionesRuntime = opciones(['claude', 'codex', 'opencode', 'kimi'], a.runtime);
1423
+ const montajes = a.mounts ?? [];
1424
+ return `
1425
+ <div class="fichaRPG">
1426
+ <div class="rpgCab">${caraRPG(a.avatar)}
1427
+ <div><h3>${esc(a.name)}</h3>
1428
+ <span class="rpgTags"><span class="kindChip ${esc(a.kind)}">${esc(a.kind)}</span>
1429
+ <span class="rpgRol">${esc(a.role)}</span>
1430
+ <span class="rpgHogar">${a.legacy ? 'repo' : `workspace · ${montajes.length} ${montajes.length === 1 ? 'mount' : 'mounts'}`}</span></span></div>
1431
+ <span class="rpgBotones">
1432
+ <button class="rpgMini rpgDado" type="button" data-agente="${esc(a.slug)}"
1433
+ title="Reroll this agent's face — deterministic, persisted on the card">🎲</button>
1434
+ <button class="rpgMini rpgIns" type="button" data-agente="${esc(a.slug)}"
1435
+ data-file="CLAUDE.md" title="Instructions the Claude runtime reads">CLAUDE.md</button>
1436
+ <button class="rpgMini rpgIns" type="button" data-agente="${esc(a.slug)}"
1437
+ data-file="AGENTS.md" title="Instructions Codex, OpenCode and Kimi read">AGENTS.md</button>
1438
+ </span>
1439
+ </div>
1440
+ <div class="rpgFila"><label>engine</label>
1441
+ ${barra(motor.ancho, motor.defecto ? 'defecto' : '')}
1442
+ <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="model"
1443
+ ${esClaude ? '' : 'disabled title="This window runs its own CLI; its model lives in its own flags"'}>
1444
+ ${opcionesModelo}</select></div>
1445
+ <div class="rpgFila"><label>effort</label>
1446
+ ${barra(esfuerzo / 5, esfuerzo ? '' : 'defecto')}
1447
+ <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="effort"
1448
+ ${esClaude ? '' : 'disabled title="This window runs its own CLI; its effort lives in its own flags"'}>
1449
+ ${opcionesEsfuerzo}</select></div>
1450
+ <div class="rpgFila"><label>provider</label>
1451
+ <span class="rpgDato"><span class="cliDot ${
1452
+ a.cli.connected ? 'on' : a.cli.installed ? 'idle' : 'off'
1453
+ }" title="${esc(
1454
+ a.cli.connected
1455
+ ? 'connected: this agent’s window is alive right now'
1456
+ : a.cli.installed
1457
+ ? 'installed on this machine, not in use by this agent'
1458
+ : `${a.cli.binary} is not installed`,
1459
+ )}"></span>${esc(
1460
+ a.cli.connected ? 'connected' : a.cli.installed ? 'idle' : 'missing',
1461
+ )} · ${esc(a.cli.binary)}
1462
+ <button class="rpgMini rpgTest" type="button" data-agente="${esc(a.slug)}"
1463
+ title="Run the engine for real: --version, and the login state on Claude">test</button></span>
1464
+ <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="runtime">
1465
+ ${opcionesRuntime}</select></div>
1466
+ <div class="rpgSkills"><label>works on · ${montajes.length}</label>
1467
+ ${
1468
+ montajes.length
1469
+ ? montajes
1470
+ .map(
1471
+ (m) =>
1472
+ `<code class="mono" title="${esc(m.target)}">${esc(m.label)}${
1473
+ m.fixed
1474
+ ? ''
1475
+ : `<button class="rpgQuitar rpgDesmonta" type="button"
1476
+ data-agente="${esc(a.slug)}" data-mount="${esc(m.label)}"
1477
+ title="Stop this agent working on ${esc(m.target)}">×</button>`
1478
+ }</code>`,
1479
+ )
1480
+ .join(' ')
1481
+ : '<span class="rpgDato">nothing mounted yet</span>'
1482
+ }
1483
+ ${
1484
+ a.legacy
1485
+ ? '<span class="rpgDato">a legacy repo agent works on its own repo</span>'
1486
+ : `<button class="rpgMini rpgMonta" type="button" data-agente="${esc(a.slug)}"
1487
+ title="A repo, a worktree or a folder of documents — this agent works on all of them">+ folder</button>`
1488
+ }</div>
1489
+ <div class="rpgFila"><label>growth</label>
1490
+ ${barra(crecido)}
1491
+ <span class="rpgDato">${g.floors | 0} floors · ${g.bricks | 0} bricks · ${g.activity30 | 0}/30d</span></div>
1492
+ <div class="rpgSkills"><label>skills · ${skills.length}</label>
1493
+ ${
1494
+ skills.length
1495
+ ? skills
1496
+ .map(
1497
+ (s) =>
1498
+ `<code class="mono" title="${esc(s.description)}">${esc(s.name)}${
1499
+ s.removable && s.dir
1500
+ ? `<button class="rpgQuitar" type="button" data-agente="${esc(a.slug)}"
1501
+ data-skill="${esc(s.dir)}" title="Remove this skill from the agent's home">×</button>`
1502
+ : ''
1503
+ }</code>`,
1504
+ )
1505
+ .join(' ')
1506
+ : '<span class="rpgDato">none discovered</span>'
1507
+ }
1508
+ <label class="rpgMini rpgSubir"
1509
+ title="Install a skill zip into this agent's own home — the Claude runtime reads skills; other engines ignore them">
1510
+ + zip<input type="file" accept=".zip" data-agente="${esc(a.slug)}"></label></div>
1511
+ </div>`;
1512
+ };
1513
+
1514
+ q('#lienzo').innerHTML = `<div><span class="sub">agents &amp; skills</span>
1515
+ <h1 style="margin-top:6px">One seat, the agents behind it</h1>
1516
+ <p class="prosa" style="margin-top:8px">A city is its agents, and each one is
1517
+ whole here: the kind of work it does, its role, everything it works on — any
1518
+ number of repositories and document folders at once — the engine and effort
1519
+ that run it, and the skills in its own home. Every number is real: growth
1520
+ from what the agent actually produced, skills from live read-only
1521
+ recognition. Changes persist to the card and apply the next time the session
1522
+ opens.</p>
1523
+ <button class="rpgAlta" type="button" id="altaAgente">+ Add an agent</button></div>
1524
+ <div class="fichasRPG">
1525
+ <div class="fichaRPG rpgSeat">
1526
+ <div class="rpgCab">${caraRPG(E.avatars?.['seat'])}
1527
+ <div><h3>seat</h3>
1528
+ <span class="rpgTags"><span class="kindChip coordinator">chair</span>
1529
+ <span class="rpgRol">${esc(mia?.role ?? 'not configured')}</span></span></div>
1530
+ </div>
1531
+ <div class="rpgFila"><span class="rpgDato">${esc(E.address)}</span></div>
1532
+ <div class="rpgFila"><span class="rpgDato">${esc(mia?.objetivo?.title ?? 'No goal yet')}</span></div>
1533
+ </div>
1534
+ ${agentes.map(fichaDe).join('')}
1535
+ </div>`;
1536
+
1537
+ // Engine changes: one POST, the sheet re-renders from the server's answer.
1538
+ // One save round-trip for every sheet edit: POST, refresh the sheet from the
1539
+ // server's answer, keep the roster's face in sync, repaint.
1540
+ const guardaAgente = async (carga: Record<string, string>, mensaje: string) => {
1541
+ let r: { ok?: boolean; agent?: FichaAgente; error?: string };
1542
+ try {
1543
+ r = await api('/api/agente', { method: 'POST', body: JSON.stringify(carga) });
1544
+ } catch (e) {
1545
+ toast(String(e), true);
1546
+ pinta(); // the failed edit must not stay on screen looking saved
1547
+ return;
1548
+ }
1549
+ if (!r.ok || !r.agent) {
1550
+ toast(r.error || 'Could not save', true);
1551
+ // Repaint from the untouched state: a select left showing "custom…"
1552
+ // never fires another change event for the same choice.
1553
+ pinta();
1554
+ return;
1555
+ }
1556
+ E.agents = (E.agents ?? []).map((x) => (x.slug === r.agent!.slug ? r.agent! : x));
1557
+ if (E.avatars) E.avatars[r.agent.name] = r.agent.avatar;
1558
+ toast(mensaje);
1559
+ pinta();
1560
+ };
1561
+
1562
+ todos<HTMLSelectElement>('.rpgSel').forEach((sel) => {
1563
+ sel.onchange = async () => {
1564
+ let valor = sel.value;
1565
+ if (valor === '__otro__') {
1566
+ // The CLI resolves any alias at launch; the prompt just has to be an
1567
+ // alias shape, and the server refuses anything that is not.
1568
+ const propio = window.prompt('Model alias (resolved by the CLI at launch):', '');
1569
+ if (propio === null) {
1570
+ pinta();
1571
+ return;
1572
+ }
1573
+ valor = propio.trim().toLowerCase();
1574
+ }
1575
+ await guardaAgente(
1576
+ { agent: sel.dataset.agente ?? '', [sel.dataset.campo ?? 'model']: valor },
1577
+ 'Saved — applies next session',
1578
+ );
1579
+ };
1580
+ });
1581
+
1582
+ // The dice: one reroll, one new deterministic look, persisted on the card.
1583
+ todos<HTMLButtonElement>('.rpgDado').forEach((boton) => {
1584
+ boton.onclick = () =>
1585
+ guardaAgente(
1586
+ { agent: boton.dataset.agente ?? '', avatar: Math.random().toString(36).slice(2, 8) },
1587
+ 'New look — same identity, everywhere, forever',
1588
+ );
1589
+ });
1590
+
1591
+ // The test button: the engine runs for real and answers for itself.
1592
+ todos<HTMLButtonElement>('.rpgTest').forEach((boton) => {
1593
+ boton.onclick = async () => {
1594
+ boton.disabled = true;
1595
+ try {
1596
+ const r = await api<{
1597
+ ok?: boolean;
1598
+ binary?: string;
1599
+ version?: string;
1600
+ detail?: string;
1601
+ error?: string;
1602
+ }>('/api/motor', {
1603
+ method: 'POST',
1604
+ body: JSON.stringify({ agent: boton.dataset.agente ?? '' }),
1605
+ });
1606
+ if (r.error) {
1607
+ toast(r.error, true);
1608
+ return;
1609
+ }
1610
+ const partes = [r.version, r.detail].filter(Boolean).join(' · ');
1611
+ toast(
1612
+ r.ok ? `${r.binary} works — ${partes}` : `${r.binary}: ${r.detail || 'failed'}`,
1613
+ !r.ok,
1614
+ );
1615
+ } catch (e) {
1616
+ toast(String(e), true);
1617
+ } finally {
1618
+ // Not on the success line: a dropped request must not leave the
1619
+ // button greyed out until the whole view re-renders.
1620
+ boton.disabled = false;
1621
+ }
1622
+ };
1623
+ });
1624
+
1625
+ // Removing a skill: only ones living in the agent's own `.claude/skills/` —
1626
+ // a skill committed elsewhere in a repo is the repo's property, no × shown.
1627
+ todos<HTMLButtonElement>('.rpgQuitar').forEach((boton) => {
1628
+ boton.onclick = async () => {
1629
+ const nombre = boton.dataset.skill ?? '';
1630
+ if (!window.confirm(`Remove the skill ${nombre} from this agent's home?`)) return;
1631
+ const r = await api<{ ok?: boolean; error?: string }>('/api/skill', {
1632
+ method: 'POST',
1633
+ body: JSON.stringify({ agent: boton.dataset.agente ?? '', remove: nombre }),
1634
+ });
1635
+ if (!r.ok) {
1636
+ toast(r.error || 'Could not remove', true);
1637
+ return;
1638
+ }
1639
+ toast(`Skill ${nombre} removed`);
1640
+ void refresca();
1641
+ };
1642
+ });
1643
+
1644
+ // Adding an agent from the web: the roster the wizard builds question by
1645
+ // question, reachable by somebody who never opens a terminal.
1646
+ const alta = document.getElementById('altaAgente');
1647
+ if (alta)
1648
+ alta.onclick = async () => {
1649
+ const nombre = window.prompt("The agent's name:", '');
1650
+ if (nombre === null || !nombre.trim()) return;
1651
+ const clase =
1652
+ window.prompt('What kind of work does it do? code, knowledge or coordinator', 'code') ?? '';
1653
+ if (!clase.trim()) return;
1654
+ const rol = window.prompt('Its role (its specialty, not authority):', 'blank') ?? '';
1655
+ if (!rol.trim()) return;
1656
+ try {
1657
+ const r = await api<{ ok?: boolean; error?: string }>('/api/agentes', {
1658
+ method: 'POST',
1659
+ body: JSON.stringify({ name: nombre.trim(), kind: clase.trim(), role: rol.trim() }),
1660
+ });
1661
+ if (!r.ok) {
1662
+ toast(r.error || 'Could not add the agent', true);
1663
+ return;
1664
+ }
1665
+ toast(`${nombre.trim()} joined this city — give it something to work on`);
1666
+ void refresca();
1667
+ } catch (e) {
1668
+ toast(String(e), true);
1669
+ }
1670
+ };
1671
+
1672
+ // Mounts: what an agent works on, added and removed where it is read.
1673
+ const montaje = async (carga: Record<string, string>, hecho: string) => {
1674
+ try {
1675
+ const r = await api<{ ok?: boolean; error?: string }>('/api/montaje', {
1676
+ method: 'POST',
1677
+ body: JSON.stringify(carga),
1678
+ });
1679
+ if (!r.ok) {
1680
+ toast(r.error || 'Could not change the mounts', true);
1681
+ return;
1682
+ }
1683
+ toast(hecho);
1684
+ void refresca();
1685
+ } catch (e) {
1686
+ toast(String(e), true);
1687
+ }
1688
+ };
1689
+ todos<HTMLButtonElement>('.rpgMonta').forEach((boton) => {
1690
+ boton.onclick = () => {
1691
+ const ruta = window.prompt('A repo, a worktree or a folder of documents:', '~/');
1692
+ if (ruta === null || !ruta.trim()) return;
1693
+ void montaje({ agent: boton.dataset.agente ?? '', add: ruta.trim() }, 'Mounted');
1694
+ };
1695
+ });
1696
+ todos<HTMLButtonElement>('.rpgDesmonta').forEach((boton) => {
1697
+ boton.onclick = () => {
1698
+ const etiqueta = boton.dataset.mount ?? '';
1699
+ if (!window.confirm(`Stop this agent working on ${etiqueta}?`)) return;
1700
+ // Unmounting removes a symlink and a card key. What it points at is not
1701
+ // touched — say so, because "remove" reads as "delete my folder".
1702
+ void montaje(
1703
+ { agent: boton.dataset.agente ?? '', remove: etiqueta },
1704
+ `${etiqueta} unmounted — the folder itself is untouched`,
1705
+ );
1706
+ };
1707
+ });
1708
+
1709
+ // The instruction editors: one click, the modal opens on that agent's file.
1710
+ todos<HTMLButtonElement>('.rpgIns').forEach((boton) => {
1711
+ boton.onclick = () =>
1712
+ void abreInstrucciones(boton.dataset.agente ?? '', boton.dataset.file ?? '');
1713
+ });
1714
+
1715
+ // Installing a skill: the picked zip travels as base64, like claude.ai's own
1716
+ // upload. The name only matters when the zip has no single top folder.
1717
+ todos<HTMLInputElement>('.rpgSubir input').forEach((entrada) => {
1718
+ entrada.onchange = async () => {
1719
+ const archivo = entrada.files?.[0];
1720
+ if (!archivo) return;
1721
+ entrada.disabled = true;
1722
+ try {
1723
+ const zip = await aBase64(archivo);
1724
+ const nombre = archivo.name
1725
+ .replace(/\.zip$/i, '')
1726
+ .toLowerCase()
1727
+ .replace(/[^a-z0-9-]+/g, '-')
1728
+ .replace(/^-+|-+$/g, '');
1729
+ const r = await api<{ ok?: boolean; skill?: string; error?: string }>('/api/skill', {
1730
+ method: 'POST',
1731
+ body: JSON.stringify({ agent: entrada.dataset.agente ?? '', name: nombre, zip }),
1732
+ });
1733
+ if (!r.ok) {
1734
+ toast(r.error || 'Could not install', true);
1735
+ return;
1736
+ }
1737
+ toast(`Skill ${r.skill} installed — the Claude runtime reads it next session`);
1738
+ void refresca();
1739
+ } catch (e) {
1740
+ toast(String(e), true);
1741
+ } finally {
1742
+ entrada.disabled = false;
1743
+ entrada.value = '';
1744
+ }
1745
+ };
1746
+ });
1747
+ };
1748
+
1749
+ function aBase64(archivo: File): Promise<string> {
1750
+ // The browser's own encoder, off a data URL: native and allocation-friendly,
1751
+ // where hand-chunked String.fromCharCode reallocated megabytes on the way.
1752
+ return new Promise((listo, falla) => {
1753
+ const lector = new FileReader();
1754
+ lector.onerror = () => falla(lector.error);
1755
+ lector.onload = () => listo(String(lector.result).split(',', 2)[1] ?? '');
1756
+ lector.readAsDataURL(archivo);
1757
+ });
1758
+ }
1759
+
1760
+ /** The instruction editor: one agent, one file, one honest label saying which
1761
+ * engine actually reads it. Saved atomically into the agent's own home. */
1762
+ let editorAbierto: { agent: string; file: string } | null = null;
1763
+
1764
+ async function abreInstrucciones(agente: string, archivo: string): Promise<void> {
1765
+ const r = await api<{
1766
+ exists?: boolean;
1767
+ content?: string;
1768
+ home?: string;
1769
+ reader?: string;
1770
+ error?: string;
1771
+ }>(`/api/instrucciones?agent=${encodeURIComponent(agente)}&file=${encodeURIComponent(archivo)}`);
1772
+ if (r.error) {
1773
+ toast(r.error, true);
1774
+ return;
1775
+ }
1776
+ editorAbierto = { agent: agente, file: archivo };
1777
+ q<HTMLElement>('#edTitulo').textContent = `${agente} · ${archivo}`;
1778
+ q<HTMLElement>('#edLector').textContent =
1779
+ `read by ${r.reader ?? '?'}${r.exists ? '' : ' · new file'}`;
1780
+ q<HTMLElement>('#edRuta').textContent = r.home ? `${r.home}/${archivo}` : '';
1781
+ q<HTMLTextAreaElement>('#edTexto').value = r.content ?? '';
1782
+ q<HTMLElement>('#editorIns').hidden = false;
1783
+ q<HTMLTextAreaElement>('#edTexto').focus();
1784
+ }
1785
+
1786
+ document.getElementById('edCerrar')?.addEventListener('click', () => {
1787
+ q<HTMLElement>('#editorIns').hidden = true;
1788
+ editorAbierto = null;
1789
+ });
1790
+ document.getElementById('edGuardar')?.addEventListener('click', async () => {
1791
+ if (!editorAbierto) return;
1792
+ const r = await api<{ ok?: boolean; error?: string }>('/api/instrucciones', {
1793
+ method: 'POST',
1794
+ body: JSON.stringify({ ...editorAbierto, content: q<HTMLTextAreaElement>('#edTexto').value }),
1795
+ });
1796
+ if (!r.ok) {
1797
+ toast(r.error || 'Could not save', true);
1798
+ return;
1799
+ }
1800
+ toast('Saved');
1801
+ q<HTMLElement>('#editorIns').hidden = true;
1802
+ editorAbierto = null;
1803
+ });
1804
+
1805
+ // ── shared ──────────────────────────────────────────────────────────────────
1806
+ function enlaza(): void {
1807
+ todos<HTMLButtonElement>('[data-copia]').forEach((b) => {
1808
+ b.onclick = async () => {
1809
+ try {
1810
+ await navigator.clipboard.writeText(b.dataset.copia ?? '');
1811
+ toast('Copied');
1812
+ } catch {
1813
+ toast('Could not copy — select it by hand', true);
1814
+ }
1815
+ };
1816
+ });
1817
+ todos<HTMLButtonElement>('[data-ir]').forEach((b) => {
1818
+ b.onclick = () => {
1819
+ SECCION = b.dataset.ir ?? 'mapa';
1820
+ pinta();
1821
+ };
1822
+ });
1823
+ }
1824
+
1825
+ /**
1826
+ * The divider between the map and the live rail: grab it and the rail follows
1827
+ * the mouse. The width persists per browser — a rail you resized yesterday is
1828
+ * still that size today — and a double-click gives the default back. Dragging
1829
+ * disables the map iframe's pointer events for the duration, or the iframe
1830
+ * swallows the drag the moment the cursor crosses it.
1831
+ */
1832
+ function arrastreDelRail(): void {
1833
+ const app = document.getElementById('app');
1834
+ const asa = document.getElementById('liveResize');
1835
+ const rail = document.getElementById('livePanel');
1836
+ if (!app || !asa || !rail) return;
1837
+ const CLAVE = 'hall-live-width';
1838
+ const aplica = (px: number | null) => {
1839
+ if (px === null) app.style.removeProperty('--live-ancho');
1840
+ else app.style.setProperty('--live-ancho', `${Math.round(px)}px`);
1841
+ };
1842
+ // Never narrower than the cards stay readable, never wider than the map
1843
+ // stops being a map.
1844
+ const tope = (px: number) => Math.max(340, Math.min(px, Math.max(420, window.innerWidth * 0.6)));
1845
+ try {
1846
+ const guardado = Number(localStorage.getItem(CLAVE));
1847
+ if (guardado > 0) aplica(tope(guardado));
1848
+ } catch {
1849
+ // A browser with no storage gets the default width, which is fine.
1850
+ }
1851
+
1852
+ asa.addEventListener('pointerdown', (e: PointerEvent) => {
1853
+ e.preventDefault();
1854
+ const desdeX = e.clientX;
1855
+ const desdeAncho = rail.getBoundingClientRect().width;
1856
+ app.classList.add('liveResizing');
1857
+ asa.setPointerCapture(e.pointerId);
1858
+ const mueve = (ev: PointerEvent) => aplica(tope(desdeAncho + (desdeX - ev.clientX)));
1859
+ const suelta = () => {
1860
+ app.classList.remove('liveResizing');
1861
+ asa.removeEventListener('pointermove', mueve);
1862
+ asa.removeEventListener('pointerup', suelta);
1863
+ asa.removeEventListener('pointercancel', suelta);
1864
+ try {
1865
+ localStorage.setItem(CLAVE, String(Math.round(rail.getBoundingClientRect().width)));
1866
+ } catch {
1867
+ // Nothing to persist to; the drag still worked for this session.
1868
+ }
1869
+ };
1870
+ asa.addEventListener('pointermove', mueve);
1871
+ asa.addEventListener('pointerup', suelta);
1872
+ asa.addEventListener('pointercancel', suelta);
1873
+ });
1874
+ asa.addEventListener('dblclick', () => {
1875
+ aplica(null);
1876
+ try {
1877
+ localStorage.removeItem(CLAVE);
1878
+ } catch {
1879
+ // Already gone.
1880
+ }
1881
+ });
1882
+ }
1883
+
1884
+ /** The map can ask for a section: a click on the town hall opens the
1885
+ * committee, a click on a gate opens the roads. Only from the map's own frame
1886
+ * and only to sections that exist — a message is a request, not a capability. */
1887
+ window.addEventListener('message', (message) => {
1888
+ if (!esNavDeMapa(message.data)) return;
1889
+ const frame = document.querySelector<HTMLIFrameElement>('#cityMapFrame');
1890
+ if (!frame || message.source !== frame.contentWindow) return;
1891
+ if (message.origin !== new URL(frame.src, location.href).origin) return;
1892
+ const view = message.data.view;
1893
+ if (view !== 'committee' && view !== 'red') return;
1894
+ SECCION = view;
1895
+ pinta();
1896
+ });
1897
+
1898
+ /**
1899
+ * Night or day. The city's own light is night — an isometric map at 3am — so
1900
+ * that stays the default; day is the palette of agentscity.net, so the tool on
1901
+ * your machine and the site are visibly one product.
1902
+ *
1903
+ * Three states, not two: no stored choice means "follow this machine", and only
1904
+ * an explicit click stamps the root element and outranks the system setting.
1905
+ * The map lives in an iframe of its own, so it is told as well — a light Hall
1906
+ * around a night map would read as two products in one window.
1907
+ */
1908
+ /** Which skin is showing right now: the stamped choice, or this machine's. */
1909
+ function temaActual(): 'light' | 'dark' {
1910
+ const elegido = document.documentElement.getAttribute('data-tema');
1911
+ if (elegido) return elegido === 'claro' ? 'light' : 'dark';
1912
+ return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
1913
+ }
1914
+
1915
+ function tema(): void {
1916
+ const boton = document.getElementById('temaBoton');
1917
+ const guardado = ((): string => {
1918
+ try {
1919
+ return localStorage.getItem('hall-tema') ?? '';
1920
+ } catch {
1921
+ return ''; // a browser with site data blocked still gets a working page
1922
+ }
1923
+ })();
1924
+ const claroDelSistema = window.matchMedia?.('(prefers-color-scheme: light)').matches ?? false;
1925
+
1926
+ const pon = (elegido: string): void => {
1927
+ const raiz = document.documentElement;
1928
+ if (elegido) raiz.setAttribute('data-tema', elegido);
1929
+ else raiz.removeAttribute('data-tema');
1930
+ const claro = elegido ? elegido === 'claro' : claroDelSistema;
1931
+ if (boton) boton.textContent = claro ? '☀ Day' : '☾ Night';
1932
+ const frame = document.querySelector<HTMLIFrameElement>('#cityMapFrame');
1933
+ frame?.contentWindow?.postMessage(
1934
+ { type: 'agents-city-map-theme/1', theme: claro ? 'light' : 'dark' },
1935
+ new URL(frame.src, location.href).origin,
1936
+ );
1937
+ };
1938
+
1939
+ pon(guardado);
1940
+ if (boton)
1941
+ boton.onclick = () => {
1942
+ const claroAhora = document.documentElement.getAttribute('data-tema')
1943
+ ? document.documentElement.getAttribute('data-tema') === 'claro'
1944
+ : claroDelSistema;
1945
+ const elegido = claroAhora ? 'oscuro' : 'claro';
1946
+ try {
1947
+ localStorage.setItem('hall-tema', elegido);
1948
+ } catch {
1949
+ /* not being able to remember it is not a reason to refuse the switch */
1950
+ }
1951
+ pon(elegido);
1952
+ };
1953
+ }
1954
+
1955
+ arrastreDelRail();
1956
+ tema();
1957
+ void refresca();
1958
+
1959
+ export {};