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,508 @@
1
+ #!/usr/bin/env python3
2
+ """Browser spectator E2E: happy committee, moderated replies and hard failures."""
3
+ import json
4
+ import os
5
+ import queue
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ import urllib.parse
13
+
14
+ AQUI = os.path.dirname(os.path.abspath(__file__))
15
+ RAIZ = os.path.dirname(AQUI)
16
+ sys.path.insert(0, AQUI)
17
+ from testlib import afirma, detiene_hubs_de_ciudad, resumen # noqa: E402
18
+
19
+ CLIENT = os.path.join(RAIZ, 'plugin', 'channel', 'client.js')
20
+ CLAUDE_ACTIVITY = os.path.join(RAIZ, 'plugin', 'hooks', 'activity.sh')
21
+ CHANNEL_DIR = os.path.join(RAIZ, 'plugin', 'channel')
22
+ WATCH_JS = r"""
23
+ const WebSocket = require('ws');
24
+ const readline = require('readline');
25
+ const ws = new WebSocket(process.argv[1], { origin: process.argv[2] });
26
+ ws.on('open', () => process.stdout.write(JSON.stringify({type:'test.open'}) + '\n'));
27
+ ws.on('message', raw => process.stdout.write(String(raw) + '\n'));
28
+ ws.on('unexpected-response', (_request, response) => {
29
+ process.stdout.write(JSON.stringify({type:'test.rejected', status:response.statusCode}) + '\n');
30
+ process.exit(3);
31
+ });
32
+ ws.on('error', error => process.stderr.write(error.message + '\n'));
33
+ readline.createInterface({input:process.stdin}).on('line', line => {
34
+ if (ws.readyState === WebSocket.OPEN) ws.send(line);
35
+ });
36
+ process.on('SIGTERM', () => { try { ws.close(); } catch {} process.exit(0); });
37
+ """
38
+ REJECT_JS = r"""
39
+ const WebSocket = require('ws');
40
+ const ws = new WebSocket(process.argv[1], { origin: process.argv[2] });
41
+ let done = false;
42
+ const finish = code => {
43
+ if (done) return;
44
+ done = true;
45
+ try { ws.close(); } catch {}
46
+ process.exit(code);
47
+ };
48
+ ws.on('open', () => finish(2));
49
+ ws.on('unexpected-response', (_request, response) => finish(response.statusCode === 403 ? 0 : 3));
50
+ ws.on('error', () => {});
51
+ setTimeout(() => finish(4), 4000);
52
+ """
53
+
54
+
55
+ class Watcher:
56
+ def __init__(self, url):
57
+ self.messages = []
58
+ self.lines = queue.Queue()
59
+ self.errors = []
60
+ self.process = subprocess.Popen(
61
+ ['node', '--input-type=commonjs', '-e', WATCH_JS, url,
62
+ 'http://127.0.0.1:43210'],
63
+ cwd=CHANNEL_DIR, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
64
+ stderr=subprocess.PIPE, text=True, bufsize=1)
65
+ threading.Thread(target=self._read, daemon=True).start()
66
+ threading.Thread(target=self._read_errors, daemon=True).start()
67
+
68
+ def _read(self):
69
+ for line in self.process.stdout:
70
+ try:
71
+ value = json.loads(line)
72
+ except json.JSONDecodeError:
73
+ continue
74
+ self.messages.append(value)
75
+ self.lines.put(value)
76
+
77
+ def _read_errors(self):
78
+ for line in self.process.stderr:
79
+ self.errors.append(line.rstrip())
80
+
81
+ def wait(self, condition, seconds=8):
82
+ limit = time.monotonic() + seconds
83
+ for message in self.messages:
84
+ if condition(message):
85
+ return message
86
+ while time.monotonic() < limit:
87
+ try:
88
+ message = self.lines.get(timeout=max(.01, min(.2, limit - time.monotonic())))
89
+ except queue.Empty:
90
+ continue
91
+ if condition(message):
92
+ return message
93
+ return {}
94
+
95
+ def send(self, value):
96
+ self.process.stdin.write(json.dumps(value) + '\n')
97
+ self.process.stdin.flush()
98
+
99
+ def close(self):
100
+ if self.process.poll() is None:
101
+ self.process.terminate()
102
+ try:
103
+ self.process.wait(timeout=2)
104
+ except subprocess.TimeoutExpired:
105
+ self.process.kill()
106
+
107
+
108
+ def command(env, actor, verb, thread='', payload=None):
109
+ args = ['node', CLIENT, 'committee', verb]
110
+ if thread:
111
+ args.append(thread)
112
+ body = None
113
+ if payload is not None:
114
+ args += ['--input', '-']
115
+ body = json.dumps(payload)
116
+ return subprocess.run(
117
+ args, input=body, capture_output=True, text=True,
118
+ env=dict(env, CITY_BUS_ACTOR=actor), timeout=12)
119
+
120
+
121
+ def publish_activity(env, actor, payload, thread=''):
122
+ args = ['node', CLIENT, 'activity', 'publish']
123
+ if thread:
124
+ args.append(thread)
125
+ args += ['--input', '-']
126
+ return subprocess.run(
127
+ args, input=json.dumps(payload), capture_output=True, text=True,
128
+ env=dict(env, CITY_BUS_ACTOR=actor), timeout=12)
129
+
130
+
131
+ def claude_hook(env, actor, event, payload):
132
+ return subprocess.run(
133
+ [CLAUDE_ACTIVITY, event], input=json.dumps(payload), capture_output=True,
134
+ text=True, env=dict(env, CITY_BUS_ACTOR=actor,
135
+ CLAUDE_PLUGIN_ROOT=os.path.join(RAIZ, 'plugin')),
136
+ timeout=12)
137
+
138
+
139
+ def value(result):
140
+ try:
141
+ return json.loads(result.stdout)
142
+ except json.JSONDecodeError:
143
+ return {}
144
+
145
+
146
+ def spectator_url(endpoint, token=None):
147
+ parsed = urllib.parse.urlparse(endpoint['url'])
148
+ query = urllib.parse.urlencode({
149
+ 'mode': 'spectator',
150
+ 'token': token if token is not None else endpoint['spectatorToken'],
151
+ })
152
+ return urllib.parse.urlunparse(parsed._replace(query=query))
153
+
154
+
155
+ def rejected(url, origin='http://127.0.0.1:43210'):
156
+ return subprocess.run(
157
+ ['node', '--input-type=commonjs', '-e', REJECT_JS, url, origin],
158
+ cwd=CHANNEL_DIR, capture_output=True, text=True, timeout=6).returncode == 0
159
+
160
+
161
+ def events_from(message):
162
+ return message.get('events', []) if message.get('type') == 'activity.state' else []
163
+
164
+
165
+ def ordered(actual, expected):
166
+ cursor = 0
167
+ for item in actual:
168
+ if cursor < len(expected) and item == expected[cursor]:
169
+ cursor += 1
170
+ return cursor == len(expected)
171
+
172
+
173
+ def main():
174
+ print('\n live browser committee feed over the real local WebSocket hub')
175
+ base = tempfile.mkdtemp(prefix='agents-city-live-feed-')
176
+ app = os.path.join(base, 'app')
177
+ city = os.path.join(app, 'alice', 'home')
178
+ os.makedirs(city)
179
+ open(os.path.join(city, 'city.yml'), 'w', encoding='utf-8').write(
180
+ 'id: city_live_feed\nname: Home\nslug: home\nowner: alice\n')
181
+ open(os.path.join(city, 'roads.json'), 'w', encoding='utf-8').write(
182
+ '{"version": 1, "roads": []}\n')
183
+ open(os.path.join(city, 'alice.md'), 'w', encoding='utf-8').write(
184
+ '---\nuser: alice\nagent: alice/ceo\nrepos: [api, web, ops]\n'
185
+ 'role.api: data-engineer\nrole.web: seo\nrole.ops: quality\n'
186
+ 'runs.api: claude\nruns.web: codex\nruns.ops: opencode\n---\n')
187
+ env = dict(os.environ, AGENTS_CITY_HOME=app, AGENTS_CITY_DATA=city,
188
+ AGENTS_CITY_USER='alice')
189
+ for key in ('CITY_BUS_URL', 'CITY_BUS_TOKEN', 'CITY_DIR'):
190
+ env.pop(key, None)
191
+
192
+ watcher = reconnect = None
193
+ try:
194
+ opened = command(env, 'seat', 'open', payload={
195
+ 'question': 'Do we release the migration?',
196
+ 'desiredOutcome': 'A verified go or no-go',
197
+ 'definitionOfDone': ['rollback reproduced', 'decision verified'],
198
+ 'participants': ['api', 'web'],
199
+ 'authority': 'decide',
200
+ 'maxRebuttals': 2,
201
+ })
202
+ state = value(opened)
203
+ thread = state.get('id', '')
204
+ endpoint_path = os.path.join(
205
+ app, '.runtime', 'bus', 'city-live-feed', 'endpoint.json')
206
+ endpoint = json.load(open(endpoint_path, encoding='utf-8'))
207
+ afirma('· a committee starts the real local hub with a rotated spectator token',
208
+ opened.returncode == 0 and bool(thread) and bool(endpoint.get('spectatorToken')),
209
+ opened.stderr.strip())
210
+
211
+ watcher = Watcher(spectator_url(endpoint))
212
+ snapshot = watcher.wait(lambda message: message.get('type') == 'activity.state')
213
+ afirma('· the browser receives its initial state over WebSocket',
214
+ any(event.get('kind') == 'committee.opened'
215
+ for event in events_from(snapshot)),
216
+ '\n'.join(watcher.errors[-3:]))
217
+
218
+ # Normal runtime conversation is first-class activity too; the old
219
+ # implementation only mirrored committee.* events and left City live
220
+ # blank while the seat visibly answered in its terminal.
221
+ source_id = 'test-normal-seat-prompt-1'
222
+ normal = publish_activity(env, 'seat', {
223
+ 'sourceId': source_id,
224
+ 'kind': 'conversation.user', 'phase': 'asked', 'tone': 'question',
225
+ 'title': 'seat asked', 'summary': 'What is this city for?',
226
+ # These are deliberately ignored: identity is the socket credential.
227
+ 'actor': 'web', 'role': 'member',
228
+ }, 'runtime-thread-1')
229
+ visible_prompt = watcher.wait(
230
+ lambda message: message.get('type') == 'activity.event'
231
+ and message.get('event', {}).get('sourceId') == source_id)
232
+ prompt_event = visible_prompt.get('event', {})
233
+ afirma('· happy: a normal seat prompt appears immediately over WebSocket',
234
+ normal.returncode == 0
235
+ and prompt_event.get('summary') == 'What is this city for?'
236
+ and prompt_event.get('actor') == 'seat'
237
+ and prompt_event.get('role') == 'chair',
238
+ normal.stderr or json.dumps(visible_prompt))
239
+
240
+ # Provider reconnects and repeated hooks can report the same completed
241
+ # item. A stable source id makes that replay idempotent.
242
+ repeated = publish_activity(env, 'seat', {
243
+ 'sourceId': source_id,
244
+ 'kind': 'conversation.user', 'phase': 'asked', 'tone': 'question',
245
+ 'title': 'seat asked again', 'summary': 'DUPLICATE MUST NOT APPEAR',
246
+ }, 'runtime-thread-1')
247
+ activity_file = os.path.join(
248
+ app, '.runtime', 'bus', 'city-live-feed', 'activity.jsonl')
249
+ persisted = [json.loads(line) for line in open(activity_file, encoding='utf-8')
250
+ if line.strip()]
251
+ matching = [event for event in persisted if event.get('sourceId') == source_id]
252
+ afirma('· happy: replaying one provider item does not duplicate the feed',
253
+ repeated.returncode == 0 and len(matching) == 1
254
+ and 'DUPLICATE MUST NOT APPEAR' not in json.dumps(matching),
255
+ repeated.stderr or json.dumps(matching))
256
+
257
+ # Claude supplies visible prompt/final-answer fields through documented
258
+ # hooks. Both travel through the exact same authenticated bus.
259
+ transcript = os.path.join(base, 'claude-transcript.jsonl')
260
+ open(transcript, 'w', encoding='utf-8').write('{"visible":true}\n')
261
+ hook_prompt = claude_hook(env, 'api', 'UserPromptSubmit', {
262
+ 'session_id': 'claude-visible-session', 'transcript_path': transcript,
263
+ 'prompt': 'Inspect the migration rollback.',
264
+ })
265
+ hook_answer = claude_hook(env, 'api', 'Stop', {
266
+ 'session_id': 'claude-visible-session', 'transcript_path': transcript,
267
+ 'last_assistant_message': 'Rollback fixture passes cleanly.',
268
+ })
269
+ claude_visible = watcher.wait(
270
+ lambda message: message.get('type') == 'activity.event'
271
+ and message.get('event', {}).get('kind') == 'conversation.agent'
272
+ and message.get('event', {}).get('actor') == 'api')
273
+ afirma('· happy: Claude visible prompts and answers use the WebSocket feed',
274
+ hook_prompt.returncode == 0 and hook_answer.returncode == 0
275
+ and 'Rollback fixture passes cleanly.' in json.dumps(claude_visible),
276
+ hook_prompt.stderr + hook_answer.stderr + json.dumps(claude_visible))
277
+
278
+ # A Channel assignment is transport, not dialogue. The prompt wrapper
279
+ # must stay out of the spectator feed, while Claude's visible answer is
280
+ # grouped into the semantic committee thread left by the channel.
281
+ marker_dir = os.path.join(
282
+ app, '.runtime', 'bus', 'city-live-feed', 'claude-threads')
283
+ os.makedirs(marker_dir, exist_ok=True)
284
+ marker_path = os.path.join(marker_dir, 'api.json')
285
+ open(marker_path, 'w', encoding='utf-8').write(json.dumps({
286
+ 'thread': thread, 'envelopeId': 'assignment-1',
287
+ 'kind': 'committee.assignment',
288
+ }))
289
+ internal_prompt = (
290
+ '<channel source="plugin:city:city-bus" '
291
+ 'protocol="agents-city-bus/2">SECRET_TRANSPORT_WRAPPER</channel>')
292
+ internal = claude_hook(env, 'api', 'UserPromptSubmit', {
293
+ 'session_id': 'claude-committee-session', 'transcript_path': transcript,
294
+ 'prompt': internal_prompt,
295
+ })
296
+ internal_answer = claude_hook(env, 'api', 'Stop', {
297
+ 'session_id': 'claude-committee-session', 'transcript_path': transcript,
298
+ 'last_assistant_message': 'API recommends a reversible migration.',
299
+ })
300
+ grouped_answer = watcher.wait(
301
+ lambda message: message.get('type') == 'activity.event'
302
+ and message.get('event', {}).get('summary')
303
+ == 'API recommends a reversible migration.')
304
+ afirma('· happy: a Claude Channel answer joins the committee conversation',
305
+ internal.returncode == 0 and internal_answer.returncode == 0
306
+ and grouped_answer.get('event', {}).get('thread') == thread,
307
+ internal.stderr + internal_answer.stderr + json.dumps(grouped_answer))
308
+ afirma('· non-happy: raw Channel transport is neither shown nor persisted',
309
+ 'SECRET_TRANSPORT_WRAPPER' not in json.dumps(watcher.messages)
310
+ and 'SECRET_TRANSPORT_WRAPPER'
311
+ not in open(activity_file, encoding='utf-8').read()
312
+ and not os.path.exists(marker_path),
313
+ open(activity_file, encoding='utf-8').read())
314
+
315
+ private = 'PRIVATE_REASONING_MUST_NEVER_REACH_CITY_LIVE'
316
+ rejected_private = publish_activity(env, 'seat', {
317
+ 'sourceId': 'private-reasoning-1',
318
+ 'kind': 'conversation.reasoning', 'title': 'hidden thought',
319
+ 'summary': private,
320
+ }, 'runtime-thread-1')
321
+ afirma('· non-happy: private reasoning is rejected, not logged as activity',
322
+ rejected_private.returncode != 0
323
+ and 'private model reasoning' in rejected_private.stderr
324
+ and private not in open(activity_file, encoding='utf-8').read(),
325
+ rejected_private.stderr)
326
+
327
+ invalid = publish_activity(env, 'seat', {
328
+ 'kind': 'committee.decision.recorded', 'title': 'forged decision',
329
+ 'summary': 'A runtime tried to forge committee state.',
330
+ }, 'runtime-thread-1')
331
+ afirma('· non-happy: runtime activity cannot forge committee protocol events',
332
+ invalid.returncode != 0
333
+ and 'must describe conversation, runtime or work' in invalid.stderr,
334
+ invalid.stderr)
335
+
336
+ secret = 'SECRET_FIRST_POSITION_VISIBLE_ONLY_AFTER_BARRIER'
337
+ command(env, 'api', 'respond', thread, {
338
+ 'stance': 'conditional', 'recommendation': secret,
339
+ 'evidence': ['api migration replay 482'],
340
+ })
341
+ first = watcher.wait(lambda message: message.get('type') == 'activity.event'
342
+ and message.get('event', {}).get('kind')
343
+ == 'committee.position.submitted')
344
+ afirma('· the live feed shows progress but does not leak an early position',
345
+ bool(first) and secret not in json.dumps(watcher.messages),
346
+ json.dumps(first))
347
+
348
+ command(env, 'web', 'respond', thread, {
349
+ 'stance': 'support', 'recommendation': 'ship with a cache-safe rollback banner',
350
+ 'evidence': ['web end-to-end rollback passed'],
351
+ })
352
+ barrier = watcher.wait(lambda message: message.get('type') == 'activity.event'
353
+ and message.get('event', {}).get('kind')
354
+ == 'committee.positions.revealed')
355
+ watcher.wait(lambda message: message.get('type') == 'activity.event'
356
+ and message.get('event', {}).get('kind')
357
+ == 'committee.position.revealed'
358
+ and message.get('event', {}).get('actor') == 'web')
359
+ revealed = [message.get('event', {}) for message in watcher.messages
360
+ if message.get('type') == 'activity.event'
361
+ and message.get('event', {}).get('kind')
362
+ == 'committee.position.revealed']
363
+ afirma('· after the barrier each specialist speaks as a visible participant',
364
+ len(revealed) == 2
365
+ and secret in json.dumps(revealed)
366
+ and 'cache-safe rollback banner' in json.dumps(revealed)
367
+ and secret not in json.dumps(barrier),
368
+ json.dumps(revealed))
369
+
370
+ command(env, 'seat', 'synthesize', thread, {
371
+ 'summary': 'Both paths support a reversible release',
372
+ 'agreements': ['rollback first'], 'conflicts': ['banner cache'],
373
+ 'unknowns': [],
374
+ })
375
+ requested = value(command(env, 'api', 'floor-request', thread, {
376
+ 'basis': 'new_evidence', 'reason': 'the clean replay just completed',
377
+ 'evidence': ['immutable run 482 passed'],
378
+ }))
379
+ request_id = requested.get('myFloorRequests', [{}])[-1].get('id', '')
380
+ command(env, 'seat', 'floor-grant', thread, {'requestId': request_id})
381
+ command(env, 'api', 'reply', thread, {
382
+ 'claim': 'the migration replay is now green',
383
+ 'evidence': ['immutable run 482'],
384
+ 'consequence': 'the migration gate is satisfied',
385
+ })
386
+ spoke = watcher.wait(lambda message: message.get('type') == 'activity.event'
387
+ and message.get('event', {}).get('kind')
388
+ == 'committee.floor.spoke')
389
+ afirma('· a requested and granted intervention appears live with its evidence',
390
+ 'migration replay is now green' in json.dumps(spoke)
391
+ and 'immutable run 482' in json.dumps(spoke), json.dumps(spoke))
392
+
393
+ web_outbox = os.path.join(
394
+ app, '.runtime', 'bus', 'city-live-feed', 'outbox', 'web')
395
+ queued = []
396
+ for name in os.listdir(web_outbox):
397
+ if name.endswith('.json'):
398
+ queued.append(json.load(open(os.path.join(web_outbox, name), encoding='utf-8')))
399
+ heard = next((item for item in queued if item.get('kind') == 'committee.reply.heard'), {})
400
+ afirma('· every other member hears the chaired intervention and may seek a reply',
401
+ heard.get('payload', {}).get('reply', {}).get('actor') == 'api'
402
+ and 'request the floor' in heard.get('payload', {}).get('note', '').lower(),
403
+ json.dumps(heard))
404
+
405
+ web_request = value(command(env, 'web', 'floor-request', thread, {
406
+ 'basis': 'contradiction', 'reason': 'the CDN still serves the old banner',
407
+ 'evidence': ['edge trace cache-age=310'],
408
+ }))
409
+ web_request_id = web_request.get('myFloorRequests', [{}])[-1].get('id', '')
410
+ blocked = command(env, 'seat', 'decide', thread, {
411
+ 'outcome': 'ship', 'rationale': 'replay passed', 'owner': 'alice',
412
+ 'executor': 'api', 'verifier': 'ops',
413
+ 'verificationQuestion': 'does rollback pass?',
414
+ 'selectedEvidence': ['run 482'],
415
+ 'decisiveContributors': ['api', 'web'], 'reopenIf': ['rollback fails'],
416
+ })
417
+ rejected_event = watcher.wait(
418
+ lambda message: message.get('type') == 'activity.event'
419
+ and message.get('event', {}).get('kind') == 'committee.command.rejected')
420
+ afirma('· an illegal early decision is rejected and visibly explained',
421
+ blocked.returncode != 0 and 'pending floor request' in blocked.stderr
422
+ and 'pending floor request' in json.dumps(rejected_event),
423
+ blocked.stderr.strip())
424
+ command(env, 'seat', 'floor-deny', thread, {
425
+ 'requestId': web_request_id,
426
+ 'reason': 'the verifier will reproduce that exact cache trace',
427
+ })
428
+
429
+ command(env, 'seat', 'decide', thread, {
430
+ 'outcome': 'ship after clean rollback',
431
+ 'rationale': 'migration evidence is green and cache risk is assigned',
432
+ 'owner': 'alice', 'executor': 'api', 'verifier': 'ops',
433
+ 'verificationQuestion': 'do clean rollback and cache checks pass?',
434
+ 'selectedEvidence': ['run 482', 'web rollback e2e'],
435
+ 'decisiveContributors': ['api', 'web'],
436
+ 'dissent': ['web retains a cache concern'], 'reopenIf': ['cache check fails'],
437
+ })
438
+ command(env, 'ops', 'verify', thread, {
439
+ 'result': 'pass', 'evidence': ['clean replay 483'],
440
+ 'checks': ['rollback', 'CDN cache'], 'residualRisks': [],
441
+ })
442
+ command(env, 'seat', 'close', thread, {
443
+ 'summary': 'Release authorised after independent verification',
444
+ 'learnings': ['cache belongs in the rollback fixture'], 'followups': [],
445
+ })
446
+ closed = watcher.wait(lambda message: message.get('type') == 'activity.event'
447
+ and message.get('event', {}).get('kind') == 'committee.closed')
448
+ afirma('· the happy path reaches a verified, visible closure',
449
+ 'Release authorised' in json.dumps(closed), json.dumps(closed))
450
+
451
+ watcher.send({
452
+ 'type': 'command', 'requestId': 'browser-write',
453
+ 'command': 'committee.cancel', 'thread': thread,
454
+ 'payload': {'reason': 'browser tried to take control'},
455
+ })
456
+ read_only = watcher.wait(lambda message: message.get('type') == 'result'
457
+ and message.get('requestId') == 'browser-write')
458
+ afirma('· the browser WebSocket is a mirror and cannot mutate the committee',
459
+ read_only.get('ok') is False
460
+ and 'read-only' in read_only.get('error', ''), json.dumps(read_only))
461
+
462
+ afirma('· a guessed spectator token is refused at the WebSocket handshake',
463
+ rejected(spectator_url(endpoint, 'guessed-token')))
464
+ afirma('· even a valid token is refused from a non-local browser origin',
465
+ rejected(spectator_url(endpoint), 'https://evil.example'))
466
+
467
+ reconnect = Watcher(spectator_url(endpoint))
468
+ restored = reconnect.wait(lambda message: message.get('type') == 'activity.state')
469
+ restored_events = events_from(restored)
470
+ kinds = [event.get('kind') for event in restored_events
471
+ if event.get('thread') == thread]
472
+ expected = [
473
+ 'committee.opened', 'committee.position.submitted',
474
+ 'committee.position.submitted', 'committee.positions.revealed',
475
+ 'committee.position.revealed', 'committee.position.revealed',
476
+ 'committee.synthesis.published', 'committee.floor.requested',
477
+ 'committee.floor.granted', 'committee.floor.spoke',
478
+ 'committee.floor.requested', 'committee.command.rejected',
479
+ 'committee.floor.denied', 'committee.decision.recorded',
480
+ 'committee.verification.pass', 'committee.closed',
481
+ ]
482
+ afirma('· reconnect restores the ordered conversation without polling',
483
+ ordered(kinds, expected), str(kinds))
484
+ seqs = [event.get('seq') for event in restored_events]
485
+ afirma('· persisted feed sequence is monotonic and duplicate-free',
486
+ seqs == sorted(set(seqs)), str(seqs))
487
+ afirma('· the same semantic feed is durable for browser reloads',
488
+ os.path.isfile(activity_file)
489
+ and 'committee.floor.spoke' in open(activity_file, encoding='utf-8').read())
490
+ diagnostic_file = os.path.join(
491
+ app, '.runtime', 'bus', 'city-live-feed', 'diagnostics.jsonl')
492
+ diagnostic_text = open(diagnostic_file, encoding='utf-8').read()
493
+ afirma('· non-happy failures leave durable diagnostics without credentials',
494
+ 'command.rejected' in diagnostic_text
495
+ and endpoint['spectatorToken'] not in diagnostic_text,
496
+ diagnostic_text[-1000:])
497
+ finally:
498
+ if watcher:
499
+ watcher.close()
500
+ if reconnect:
501
+ reconnect.close()
502
+ detiene_hubs_de_ciudad(city)
503
+ shutil.rmtree(base, ignore_errors=True)
504
+ return resumen('live-feed')
505
+
506
+
507
+ if __name__ == '__main__':
508
+ sys.exit(main())
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """The Hall, in a real browser that clicks.
3
+
4
+ ./bin/test-navegador.py
5
+
6
+ Every other suite in this repo can pass while the page is dead. The instruction
7
+ editors and the skill upload once shipped rendered, typechecked, and wired to
8
+ nothing at all: `tsc` cannot see a handler that was never attached, and a
9
+ DOM-free assertion cannot click. This suite is the answer to that class of bug,
10
+ and it exists because that bug shipped.
11
+
12
+ It serves a real Hall over loopback with a throwaway city behind it, drives
13
+ headless Chrome over the DevTools protocol (`bin/navegador.mjs`, no driver
14
+ library), and asserts that the controls DO something. Without a browser on the
15
+ machine it says so and passes; CI sets CITY_BROWSER_REQUIRED=1 so a runner that
16
+ lost its Chrome fails loudly instead of quietly testing nothing.
17
+ """
18
+
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import threading
25
+
26
+ AQUI = os.path.dirname(os.path.abspath(__file__))
27
+ RAIZ = os.path.dirname(AQUI)
28
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
29
+ sys.path.insert(0, AQUI)
30
+
31
+ import serve # noqa: E402
32
+ from testlib import afirma, resumen # noqa: E402
33
+
34
+
35
+ def ciudad_de_prueba(datos):
36
+ """A city with one agent in it, so the sheets have something to draw."""
37
+ with open(os.path.join(datos, "city.yml"), "w", encoding="utf-8") as f:
38
+ f.write("id: city_browser_v1\nname: Browser Test\nslug: browser\nowner: navtest\n")
39
+ taller = os.path.join(datos, "agents", "notas")
40
+ os.makedirs(taller, exist_ok=True)
41
+ with open(os.path.join(taller, "apuntes.md"), "w", encoding="utf-8") as f:
42
+ f.write("# notas\n")
43
+ with open(os.path.join(datos, "navtest.md"), "w", encoding="utf-8") as f:
44
+ f.write(
45
+ "---\n"
46
+ "user: navtest\nname: Nav Test\nrole: cpto\nagent: navtest/seat\n"
47
+ "agents: [notas]\nkind.notas: knowledge\nrole.notas: apuntes\n"
48
+ "goals_defined: false\n---\n\n# navtest\n"
49
+ )
50
+
51
+
52
+ def bundle_al_dia():
53
+ """Rebuild the Hall bundle when this machine can, so the browser drives the
54
+ CURRENT source.
55
+
56
+ The bundles are committed so an install needs no build step, which means a
57
+ stale one is exactly what a browser test would happily pass against — the
58
+ bug it exists to catch, one level down. Where esbuild is not installed the
59
+ committed bundle is what ships, and testing that is honest too.
60
+ """
61
+ web = os.path.join(RAIZ, "city", "web")
62
+ if not os.path.isdir(os.path.join(web, "node_modules", "esbuild")):
63
+ return "committed bundle (no esbuild here)"
64
+ hecho = subprocess.run(
65
+ [
66
+ os.path.join(web, "node_modules", ".bin", "esbuild"),
67
+ os.path.join(web, "src", "hall.ts"),
68
+ "--bundle",
69
+ "--format=esm",
70
+ "--outfile=" + os.path.join(web, "dist-hall", "hall.js"),
71
+ ],
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=180,
75
+ )
76
+ if hecho.returncode != 0:
77
+ return "committed bundle (build failed)"
78
+ return "rebuilt from src/hall.ts"
79
+
80
+
81
+ def main():
82
+ print()
83
+ print(" the hall, in a browser that clicks")
84
+ print(f" · {bundle_al_dia()}")
85
+ datos = tempfile.mkdtemp()
86
+ casa = tempfile.mkdtemp()
87
+ serve.cities.REGISTRO = os.path.join(tempfile.mkdtemp(), "cities")
88
+ previo = {k: os.environ.get(k) for k in ("AGENTS_CITY_DATA", "AGENTS_CITY_HOME",
89
+ "AGENTS_CITY_USER", "CITY_SEARCH_IN")}
90
+ os.environ.update(
91
+ AGENTS_CITY_DATA=datos,
92
+ AGENTS_CITY_HOME=casa,
93
+ AGENTS_CITY_USER="navtest",
94
+ CITY_SEARCH_IN=tempfile.mkdtemp(),
95
+ )
96
+ ciudad_de_prueba(datos)
97
+
98
+ servidor = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
99
+ hilo = threading.Thread(target=servidor.serve_forever, daemon=True)
100
+ hilo.start()
101
+ url = f"http://127.0.0.1:{servidor.server_port}/?PASE={serve.PASE}"
102
+ try:
103
+ salida = subprocess.run(
104
+ ["node", os.path.join(AQUI, "navegador.mjs"), url],
105
+ capture_output=True,
106
+ text=True,
107
+ timeout=300,
108
+ )
109
+ texto = (salida.stdout or "") + (salida.stderr or "")
110
+ if "skipped" in texto and salida.returncode == 0:
111
+ print(" no browser on this machine — the clicking checks were skipped")
112
+ print("\n navegador ok — 0 checks (no browser on this machine)\n")
113
+ return 0
114
+ # Each browser check counts as one here: a suite that reports "1 check"
115
+ # for eleven assertions hides both its coverage and which one broke.
116
+ vistas = 0
117
+ for linea in texto.splitlines():
118
+ limpia = linea.strip()
119
+ if limpia.startswith("ok ·") or limpia.startswith("FAIL·"):
120
+ vistas += 1
121
+ afirma("· " + limpia.split("·", 1)[1].strip(), limpia.startswith("ok"), "")
122
+ if not vistas:
123
+ afirma("· the browser checks ran at all", False, texto.strip()[-500:])
124
+ elif salida.returncode != 0:
125
+ afirma("· the browser driver finished cleanly", False, texto.strip()[-300:])
126
+ finally:
127
+ servidor.shutdown()
128
+ servidor.server_close()
129
+ for k, v in previo.items():
130
+ if v is None:
131
+ os.environ.pop(k, None)
132
+ else:
133
+ os.environ[k] = v
134
+ shutil.rmtree(datos, ignore_errors=True)
135
+ shutil.rmtree(casa, ignore_errors=True)
136
+ return resumen("navegador")
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main())