agents-city 0.3.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (440) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.es.md +2021 -0
  5. package/README.md +1999 -0
  6. package/benchmarks/committee/README.md +39 -0
  7. package/benchmarks/committee/metrics.py +51 -0
  8. package/benchmarks/committee/run.py +64 -0
  9. package/benchmarks/committee/traces/01-single.json +8 -0
  10. package/benchmarks/committee/traces/02-mesh.json +16 -0
  11. package/benchmarks/committee/traces/03-chair.json +15 -0
  12. package/benchmarks/latency/README.md +35 -0
  13. package/benchmarks/latency/fake-claude-cli.mjs +119 -0
  14. package/benchmarks/latency/fake-native-server.mjs +340 -0
  15. package/benchmarks/latency/fake-tui.py +91 -0
  16. package/benchmarks/latency/live.py +380 -0
  17. package/benchmarks/stress/README.md +20 -0
  18. package/benchmarks/stress/run.py +487 -0
  19. package/bin/agents +4 -0
  20. package/bin/agents-city.js +116 -0
  21. package/bin/benchmark +17 -0
  22. package/bin/bus +6 -0
  23. package/bin/cities +3 -0
  24. package/bin/city +86 -0
  25. package/bin/committee +6 -0
  26. package/bin/demo +200 -0
  27. package/bin/exit +5 -0
  28. package/bin/hall +73 -0
  29. package/bin/hall.html +426 -0
  30. package/bin/logs +3 -0
  31. package/bin/report.py +14 -0
  32. package/bin/reset +3 -0
  33. package/bin/road +3 -0
  34. package/bin/seat +10 -0
  35. package/bin/serve.py +1428 -0
  36. package/bin/setup.py +753 -0
  37. package/bin/skills +3 -0
  38. package/bin/test +131 -0
  39. package/bin/test-adapter.py +183 -0
  40. package/bin/test-admision.py +99 -0
  41. package/bin/test-avatar.py +58 -0
  42. package/bin/test-benchmark.py +40 -0
  43. package/bin/test-broker.py +334 -0
  44. package/bin/test-cage.py +206 -0
  45. package/bin/test-card.py +402 -0
  46. package/bin/test-channel.py +379 -0
  47. package/bin/test-cities.py +453 -0
  48. package/bin/test-claude-runtime.py +252 -0
  49. package/bin/test-committee.py +340 -0
  50. package/bin/test-contracts.py +544 -0
  51. package/bin/test-crecimiento.py +164 -0
  52. package/bin/test-demo.py +308 -0
  53. package/bin/test-doctor.py +106 -0
  54. package/bin/test-domains.py +151 -0
  55. package/bin/test-evidencia.py +50 -0
  56. package/bin/test-exit.py +128 -0
  57. package/bin/test-hall-protocol.py +93 -0
  58. package/bin/test-launch.py +169 -0
  59. package/bin/test-live-feed.py +508 -0
  60. package/bin/test-pairing.py +99 -0
  61. package/bin/test-parcels.py +283 -0
  62. package/bin/test-runtime-failures.py +252 -0
  63. package/bin/test-runtime-ui.py +488 -0
  64. package/bin/test-runtime.py +268 -0
  65. package/bin/test-rutas.py +80 -0
  66. package/bin/test-seat.py +1386 -0
  67. package/bin/test-security.py +150 -0
  68. package/bin/test-serve.py +1057 -0
  69. package/bin/test-stress.py +64 -0
  70. package/bin/test-widgets.py +143 -0
  71. package/bin/test-workspace.py +188 -0
  72. package/bin/testlib.py +95 -0
  73. package/bin/tokens.py +14 -0
  74. package/bus/scripts/deploy.sh +60 -0
  75. package/bus/scripts/mint-token.sh +68 -0
  76. package/bus/scripts/setup-dev.sh +79 -0
  77. package/bus/scripts/test-channel.ts +8 -0
  78. package/bus/scripts/test-hub.ts +266 -0
  79. package/bus/scripts/test-local.sh +40 -0
  80. package/bus/scripts/test-queue.ts +132 -0
  81. package/bus/worker/package-lock.json +1971 -0
  82. package/bus/worker/package.json +17 -0
  83. package/bus/worker/src/index.ts +485 -0
  84. package/bus/worker/tsconfig.json +13 -0
  85. package/bus/worker/wrangler.toml +32 -0
  86. package/city/oven/README.md +59 -0
  87. package/city/oven/collect.py +43 -0
  88. package/city/oven/oven.html +4 -0
  89. package/city/scripts/history.py +120 -0
  90. package/city/scripts/seed.py +442 -0
  91. package/city/web/assets/modelos/License.txt +28 -0
  92. package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
  93. package/city/web/assets/modelos/building-a.glb +0 -0
  94. package/city/web/assets/modelos/building-b.glb +0 -0
  95. package/city/web/assets/modelos/building-c.glb +0 -0
  96. package/city/web/assets/modelos/building-d.glb +0 -0
  97. package/city/web/assets/modelos/building-e.glb +0 -0
  98. package/city/web/assets/modelos/building-f.glb +0 -0
  99. package/city/web/assets/modelos/building-g.glb +0 -0
  100. package/city/web/assets/modelos/building-h.glb +0 -0
  101. package/city/web/assets/sprites/building-a.png +0 -0
  102. package/city/web/assets/sprites/building-b.png +0 -0
  103. package/city/web/assets/sprites/building-c.png +0 -0
  104. package/city/web/assets/sprites/building-d.png +0 -0
  105. package/city/web/assets/sprites/building-e.png +0 -0
  106. package/city/web/assets/sprites/building-f.png +0 -0
  107. package/city/web/assets/sprites/building-g.png +0 -0
  108. package/city/web/assets/sprites/building-h.png +0 -0
  109. package/city/web/assets/sprites/building-i.png +0 -0
  110. package/city/web/assets/sprites/building-j.png +0 -0
  111. package/city/web/assets/sprites/building-k.png +0 -0
  112. package/city/web/assets/sprites/building-l.png +0 -0
  113. package/city/web/assets/sprites/building-m.png +0 -0
  114. package/city/web/assets/sprites/building-n.png +0 -0
  115. package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
  116. package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
  117. package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
  118. package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
  119. package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
  120. package/city/web/assets/sprites/catalogo.json +47 -0
  121. package/city/web/assets/sprites/detail-awning-wide.png +0 -0
  122. package/city/web/assets/sprites/detail-awning.png +0 -0
  123. package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
  124. package/city/web/assets/sprites/detail-overhang.png +0 -0
  125. package/city/web/assets/sprites/detail-parasol-a.png +0 -0
  126. package/city/web/assets/sprites/detail-parasol-b.png +0 -0
  127. package/city/web/assets/sprites/low-detail-building-a.png +0 -0
  128. package/city/web/assets/sprites/low-detail-building-b.png +0 -0
  129. package/city/web/assets/sprites/low-detail-building-c.png +0 -0
  130. package/city/web/assets/sprites/low-detail-building-d.png +0 -0
  131. package/city/web/assets/sprites/low-detail-building-e.png +0 -0
  132. package/city/web/assets/sprites/low-detail-building-f.png +0 -0
  133. package/city/web/assets/sprites/low-detail-building-g.png +0 -0
  134. package/city/web/assets/sprites/low-detail-building-h.png +0 -0
  135. package/city/web/assets/sprites/low-detail-building-i.png +0 -0
  136. package/city/web/assets/sprites/low-detail-building-j.png +0 -0
  137. package/city/web/assets/sprites/low-detail-building-k.png +0 -0
  138. package/city/web/assets/sprites/low-detail-building-l.png +0 -0
  139. package/city/web/assets/sprites/low-detail-building-m.png +0 -0
  140. package/city/web/assets/sprites/low-detail-building-n.png +0 -0
  141. package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
  142. package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
  143. package/city/web/assets/sprites/medidas.json +248 -0
  144. package/city/web/dist/city.js +939 -0
  145. package/city/web/dist/index.html +294 -0
  146. package/city/web/dist/modelos/License.txt +28 -0
  147. package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
  148. package/city/web/dist/modelos/building-a.glb +0 -0
  149. package/city/web/dist/modelos/building-b.glb +0 -0
  150. package/city/web/dist/modelos/building-c.glb +0 -0
  151. package/city/web/dist/modelos/building-d.glb +0 -0
  152. package/city/web/dist/modelos/building-e.glb +0 -0
  153. package/city/web/dist/modelos/building-f.glb +0 -0
  154. package/city/web/dist/modelos/building-g.glb +0 -0
  155. package/city/web/dist/modelos/building-h.glb +0 -0
  156. package/city/web/dist/sprites/building-a.png +0 -0
  157. package/city/web/dist/sprites/building-b.png +0 -0
  158. package/city/web/dist/sprites/building-c.png +0 -0
  159. package/city/web/dist/sprites/building-d.png +0 -0
  160. package/city/web/dist/sprites/building-e.png +0 -0
  161. package/city/web/dist/sprites/building-f.png +0 -0
  162. package/city/web/dist/sprites/building-g.png +0 -0
  163. package/city/web/dist/sprites/building-h.png +0 -0
  164. package/city/web/dist/sprites/building-i.png +0 -0
  165. package/city/web/dist/sprites/building-j.png +0 -0
  166. package/city/web/dist/sprites/building-k.png +0 -0
  167. package/city/web/dist/sprites/building-l.png +0 -0
  168. package/city/web/dist/sprites/building-m.png +0 -0
  169. package/city/web/dist/sprites/building-n.png +0 -0
  170. package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
  171. package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
  172. package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
  173. package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
  174. package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
  175. package/city/web/dist/sprites/catalogo.json +47 -0
  176. package/city/web/dist/sprites/detail-awning-wide.png +0 -0
  177. package/city/web/dist/sprites/detail-awning.png +0 -0
  178. package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
  179. package/city/web/dist/sprites/detail-overhang.png +0 -0
  180. package/city/web/dist/sprites/detail-parasol-a.png +0 -0
  181. package/city/web/dist/sprites/detail-parasol-b.png +0 -0
  182. package/city/web/dist/sprites/low-detail-building-a.png +0 -0
  183. package/city/web/dist/sprites/low-detail-building-b.png +0 -0
  184. package/city/web/dist/sprites/low-detail-building-c.png +0 -0
  185. package/city/web/dist/sprites/low-detail-building-d.png +0 -0
  186. package/city/web/dist/sprites/low-detail-building-e.png +0 -0
  187. package/city/web/dist/sprites/low-detail-building-f.png +0 -0
  188. package/city/web/dist/sprites/low-detail-building-g.png +0 -0
  189. package/city/web/dist/sprites/low-detail-building-h.png +0 -0
  190. package/city/web/dist/sprites/low-detail-building-i.png +0 -0
  191. package/city/web/dist/sprites/low-detail-building-j.png +0 -0
  192. package/city/web/dist/sprites/low-detail-building-k.png +0 -0
  193. package/city/web/dist/sprites/low-detail-building-l.png +0 -0
  194. package/city/web/dist/sprites/low-detail-building-m.png +0 -0
  195. package/city/web/dist/sprites/low-detail-building-n.png +0 -0
  196. package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
  197. package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
  198. package/city/web/dist/sprites/medidas.json +248 -0
  199. package/city/web/dist-hall/hall.js +264 -0
  200. package/city/web/index.html +294 -0
  201. package/city/web/package.json +18 -0
  202. package/city/web/sella.py +21 -0
  203. package/city/web/src/activity-actors.ts +47 -0
  204. package/city/web/src/activity.ts +172 -0
  205. package/city/web/src/ayuntamiento.ts +580 -0
  206. package/city/web/src/draw.ts +640 -0
  207. package/city/web/src/game-speech.ts +79 -0
  208. package/city/web/src/hall.ts +1867 -0
  209. package/city/web/src/main.ts +2225 -0
  210. package/city/web/src/oven.ts +124 -0
  211. package/city/web/src/people.ts +244 -0
  212. package/city/web/src/presencia.ts +193 -0
  213. package/city/web/src/puertas.ts +180 -0
  214. package/city/web/src/repo-roles.ts +59 -0
  215. package/city/worker/package.json +16 -0
  216. package/city/worker/schema.sql +117 -0
  217. package/city/worker/src/index.ts +702 -0
  218. package/city/worker/src/square.ts +218 -0
  219. package/city/worker/wrangler.toml +58 -0
  220. package/demo/ada.md +50 -0
  221. package/demo/bruno.md +62 -0
  222. package/demo/camila.md +56 -0
  223. package/demo/city/ada.md +40 -0
  224. package/demo/city/city.yml +8 -0
  225. package/demo/city/parcels.yml +15 -0
  226. package/demo/city/roads.json +4 -0
  227. package/demo/city/units.yml +10 -0
  228. package/demo/clinica/city.yml +8 -0
  229. package/demo/clinica/parcels.yml +15 -0
  230. package/demo/clinica/roads.json +4 -0
  231. package/demo/clinica/units.yml +9 -0
  232. package/demo/clinica/vera.md +46 -0
  233. package/demo/dante.md +54 -0
  234. package/demo/despacho/city.yml +8 -0
  235. package/demo/despacho/marta.md +46 -0
  236. package/demo/despacho/parcels.yml +15 -0
  237. package/demo/despacho/roads.json +4 -0
  238. package/demo/despacho/units.yml +9 -0
  239. package/demo/elsa.md +50 -0
  240. package/demo/farid.md +52 -0
  241. package/demo/greta.md +50 -0
  242. package/demo/hugo.md +49 -0
  243. package/demo/iris.md +49 -0
  244. package/demo/jonas.md +51 -0
  245. package/demo/kira.md +51 -0
  246. package/demo/luca.md +49 -0
  247. package/demo/parcels.yml +92 -0
  248. package/demo/seed.py +20 -0
  249. package/demo/show.py +161 -0
  250. package/demo/stories.py +824 -0
  251. package/demo/units.yml +33 -0
  252. package/docs/agents-first.md +99 -0
  253. package/docs/glossary.md +55 -0
  254. package/docs/map-live-layers.md +73 -0
  255. package/docs/security.md +231 -0
  256. package/docs/self-host.md +151 -0
  257. package/docs/testing.md +205 -0
  258. package/package.json +73 -0
  259. package/plugin/.claude-plugin/plugin.json +62 -0
  260. package/plugin/.mcp.json +15 -0
  261. package/plugin/channel/activity-cli.ts +16 -0
  262. package/plugin/channel/adapter-prompts.ts +113 -0
  263. package/plugin/channel/adapter.js +4292 -0
  264. package/plugin/channel/adapter.ts +133 -0
  265. package/plugin/channel/bus.js +20188 -0
  266. package/plugin/channel/bus.ts +131 -0
  267. package/plugin/channel/city-config.ts +125 -0
  268. package/plugin/channel/claude-channel.ts +58 -0
  269. package/plugin/channel/cli-args.ts +75 -0
  270. package/plugin/channel/client.js +4200 -0
  271. package/plugin/channel/client.ts +38 -0
  272. package/plugin/channel/committee/activity.ts +256 -0
  273. package/plugin/channel/committee/collection.ts +154 -0
  274. package/plugin/channel/committee/decision.ts +175 -0
  275. package/plugin/channel/committee/floor.ts +135 -0
  276. package/plugin/channel/committee/guards.ts +30 -0
  277. package/plugin/channel/committee/history.ts +38 -0
  278. package/plugin/channel/committee/render.ts +102 -0
  279. package/plugin/channel/committee/service.ts +72 -0
  280. package/plugin/channel/committee/storage.ts +92 -0
  281. package/plugin/channel/committee/types.ts +163 -0
  282. package/plugin/channel/committee/view.ts +63 -0
  283. package/plugin/channel/committee-cli.ts +109 -0
  284. package/plugin/channel/delivery-metrics.ts +61 -0
  285. package/plugin/channel/delivery-queue.ts +118 -0
  286. package/plugin/channel/hub/activity-controller.ts +72 -0
  287. package/plugin/channel/hub/activity-feed.ts +169 -0
  288. package/plugin/channel/hub/committee-controller.ts +60 -0
  289. package/plugin/channel/hub/connections.ts +48 -0
  290. package/plugin/channel/hub/diagnostics.ts +67 -0
  291. package/plugin/channel/hub/envelope-validity.ts +109 -0
  292. package/plugin/channel/hub/envelopes.ts +66 -0
  293. package/plugin/channel/hub/lifecycle.ts +91 -0
  294. package/plugin/channel/hub/local-roads.ts +89 -0
  295. package/plugin/channel/hub/remote-roads.ts +165 -0
  296. package/plugin/channel/hub/road-controller.ts +99 -0
  297. package/plugin/channel/hub-client.ts +149 -0
  298. package/plugin/channel/local-hub.js +5995 -0
  299. package/plugin/channel/local-hub.ts +349 -0
  300. package/plugin/channel/map-reporter.ts +48 -0
  301. package/plugin/channel/package-lock.json +1731 -0
  302. package/plugin/channel/package.json +19 -0
  303. package/plugin/channel/protocol.ts +98 -0
  304. package/plugin/channel/road-cli.ts +18 -0
  305. package/plugin/channel/run.sh +15 -0
  306. package/plugin/channel/runtime/claude.ts +458 -0
  307. package/plugin/channel/runtime/codex-config.ts +77 -0
  308. package/plugin/channel/runtime/codex.ts +669 -0
  309. package/plugin/channel/runtime/command.ts +62 -0
  310. package/plugin/channel/runtime/factory.ts +15 -0
  311. package/plugin/channel/runtime/json-rpc.ts +154 -0
  312. package/plugin/channel/runtime/kimi.ts +277 -0
  313. package/plugin/channel/runtime/opencode.ts +248 -0
  314. package/plugin/channel/runtime/process.ts +84 -0
  315. package/plugin/channel/runtime/types.ts +45 -0
  316. package/plugin/channel/runtime-files.ts +73 -0
  317. package/plugin/channel/runtime-gateway.js +6396 -0
  318. package/plugin/channel/runtime-gateway.ts +358 -0
  319. package/plugin/channel/runtime-metrics.ts +52 -0
  320. package/plugin/channel/runtime-subscription.ts +180 -0
  321. package/plugin/channel/terminal-delivery.ts +131 -0
  322. package/plugin/channel/untrusted.ts +60 -0
  323. package/plugin/commands/committee.md +31 -0
  324. package/plugin/commands/exit.md +32 -0
  325. package/plugin/commands/goals.md +20 -0
  326. package/plugin/commands/join.md +18 -0
  327. package/plugin/commands/notice.md +20 -0
  328. package/plugin/commands/propose.md +19 -0
  329. package/plugin/commands/round.md +17 -0
  330. package/plugin/commands/session.md +22 -0
  331. package/plugin/commands/settings.md +25 -0
  332. package/plugin/commands/setup.md +21 -0
  333. package/plugin/commands/team.md +23 -0
  334. package/plugin/domains/custom.md +27 -0
  335. package/plugin/domains/finance.md +31 -0
  336. package/plugin/domains/healthcare.md +34 -0
  337. package/plugin/domains/legal.md +31 -0
  338. package/plugin/domains/marketing.md +33 -0
  339. package/plugin/domains/operations.md +31 -0
  340. package/plugin/domains/research.md +32 -0
  341. package/plugin/domains/sales.md +30 -0
  342. package/plugin/domains/software.md +37 -0
  343. package/plugin/hooks/activity.sh +6 -0
  344. package/plugin/hooks/digging.sh +68 -0
  345. package/plugin/hooks/growth.sh +42 -0
  346. package/plugin/hooks/hooks.json +106 -0
  347. package/plugin/hooks/notice-on-pr.sh +35 -0
  348. package/plugin/hooks/notice-on-stop.sh +80 -0
  349. package/plugin/hooks/notice-pending.sh +58 -0
  350. package/plugin/hooks/solo-en-ciudad.sh +23 -0
  351. package/plugin/hooks/tokens.sh +47 -0
  352. package/plugin/roles/examples/account-executive.md +25 -0
  353. package/plugin/roles/examples/ai-manager.md +34 -0
  354. package/plugin/roles/examples/associate.md +24 -0
  355. package/plugin/roles/examples/brand-lead.md +20 -0
  356. package/plugin/roles/examples/cfo.md +20 -0
  357. package/plugin/roles/examples/city-lead.md +25 -0
  358. package/plugin/roles/examples/clinical-director.md +26 -0
  359. package/plugin/roles/examples/clinical-ops.md +25 -0
  360. package/plugin/roles/examples/clinician.md +25 -0
  361. package/plugin/roles/examples/compliance.md +27 -0
  362. package/plugin/roles/examples/content.md +24 -0
  363. package/plugin/roles/examples/controller.md +27 -0
  364. package/plugin/roles/examples/cpto.md +41 -0
  365. package/plugin/roles/examples/customer-success.md +25 -0
  366. package/plugin/roles/examples/data-engineer.md +37 -0
  367. package/plugin/roles/examples/data.md +60 -0
  368. package/plugin/roles/examples/dev.md +37 -0
  369. package/plugin/roles/examples/devops.md +39 -0
  370. package/plugin/roles/examples/enablement.md +24 -0
  371. package/plugin/roles/examples/ethics.md +24 -0
  372. package/plugin/roles/examples/fin-analytics.md +27 -0
  373. package/plugin/roles/examples/health-compliance.md +25 -0
  374. package/plugin/roles/examples/health-data.md +26 -0
  375. package/plugin/roles/examples/knowledge.md +25 -0
  376. package/plugin/roles/examples/lifecycle.md +26 -0
  377. package/plugin/roles/examples/llm-engineer.md +34 -0
  378. package/plugin/roles/examples/managing-partner.md +20 -0
  379. package/plugin/roles/examples/methods.md +23 -0
  380. package/plugin/roles/examples/operations-lead.md +23 -0
  381. package/plugin/roles/examples/ops.md +24 -0
  382. package/plugin/roles/examples/patient-safety.md +26 -0
  383. package/plugin/roles/examples/performance.md +27 -0
  384. package/plugin/roles/examples/po.md +36 -0
  385. package/plugin/roles/examples/process-owner.md +23 -0
  386. package/plugin/roles/examples/product-design.md +39 -0
  387. package/plugin/roles/examples/program-manager.md +24 -0
  388. package/plugin/roles/examples/quality.md +25 -0
  389. package/plugin/roles/examples/research-director.md +24 -0
  390. package/plugin/roles/examples/research-ops.md +25 -0
  391. package/plugin/roles/examples/researcher.md +25 -0
  392. package/plugin/roles/examples/revenue-lead.md +25 -0
  393. package/plugin/roles/examples/revops.md +26 -0
  394. package/plugin/roles/examples/seo.md +27 -0
  395. package/plugin/roles/examples/specialist.md +24 -0
  396. package/plugin/scripts/admision.py +165 -0
  397. package/plugin/scripts/apaga.py +232 -0
  398. package/plugin/scripts/avatar.py +211 -0
  399. package/plugin/scripts/broker.py +531 -0
  400. package/plugin/scripts/cage.py +252 -0
  401. package/plugin/scripts/capabilities.py +197 -0
  402. package/plugin/scripts/card.py +312 -0
  403. package/plugin/scripts/cities.py +520 -0
  404. package/plugin/scripts/city-env.sh +90 -0
  405. package/plugin/scripts/city-runtime.sh +87 -0
  406. package/plugin/scripts/city-session.sh +526 -0
  407. package/plugin/scripts/city_env.py +67 -0
  408. package/plugin/scripts/crecimiento.py +140 -0
  409. package/plugin/scripts/deliberations.py +48 -0
  410. package/plugin/scripts/doctor.py +172 -0
  411. package/plugin/scripts/domains.py +201 -0
  412. package/plugin/scripts/evidencia.py +64 -0
  413. package/plugin/scripts/find-repos.sh +111 -0
  414. package/plugin/scripts/gh.py +159 -0
  415. package/plugin/scripts/hall_protocol.py +147 -0
  416. package/plugin/scripts/hook_activity.py +139 -0
  417. package/plugin/scripts/launch.py +94 -0
  418. package/plugin/scripts/logs.py +98 -0
  419. package/plugin/scripts/pairing.py +185 -0
  420. package/plugin/scripts/parcels.py +132 -0
  421. package/plugin/scripts/read-card.py +51 -0
  422. package/plugin/scripts/report.py +201 -0
  423. package/plugin/scripts/reset.py +180 -0
  424. package/plugin/scripts/roads.py +230 -0
  425. package/plugin/scripts/roles.py +274 -0
  426. package/plugin/scripts/runtime_log.py +69 -0
  427. package/plugin/scripts/runtime_processes.py +132 -0
  428. package/plugin/scripts/rutas.py +83 -0
  429. package/plugin/scripts/seat.py +1349 -0
  430. package/plugin/scripts/tokens.py +194 -0
  431. package/plugin/scripts/trust-repos.py +59 -0
  432. package/plugin/scripts/ui.py +280 -0
  433. package/plugin/scripts/units.py +80 -0
  434. package/plugin/scripts/workspace.py +364 -0
  435. package/plugin/skills/city/SKILL.md +191 -0
  436. package/templates/blank.md +39 -0
  437. package/templates/finance.md +44 -0
  438. package/templates/legal.md +45 -0
  439. package/templates/marketing.md +48 -0
  440. package/templates/product.md +45 -0
@@ -0,0 +1,1057 @@
1
+ #!/usr/bin/env python3
2
+ """The personal-city Hall server.
3
+
4
+ ./bin/test-serve.py
5
+
6
+ It is the only process in this project that both listens on a socket and writes
7
+ to disk, so its unhappy paths are security: no run token means no response, and a
8
+ foreign origin cannot drive it. The happy path pins one owner seat, several local
9
+ cities, symmetric roads and city-scoped writes.
10
+
11
+ The server runs in-process on an ephemeral port. No browser, no network beyond
12
+ loopback, and every city it writes lands in a temporary directory.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import shutil
18
+ import sys
19
+ import tempfile
20
+ import threading
21
+ import urllib.error
22
+ import urllib.parse
23
+ import urllib.request
24
+
25
+ AQUI = os.path.dirname(os.path.abspath(__file__))
26
+ RAIZ = os.path.dirname(AQUI)
27
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
28
+ sys.path.insert(0, AQUI)
29
+ import serve # noqa: E402
30
+ import card # noqa: E402
31
+
32
+ from testlib import comprueba, afirma, resumen # noqa: E402
33
+
34
+
35
+ def pide(puerto, ruta, con_pase=True, metodo="GET", cuerpo=None, cabeceras=None):
36
+ """One request against the test server. Returns (status, body-bytes)."""
37
+ url = f"http://127.0.0.1:{puerto}{ruta}"
38
+ if con_pase:
39
+ url += ("&" if "?" in ruta else "?") + "PASE=" + serve.PASE
40
+ req = urllib.request.Request(
41
+ url, method=metodo, data=json.dumps(cuerpo).encode() if cuerpo is not None else None
42
+ )
43
+ for k, v in (cabeceras or {}).items():
44
+ req.add_header(k, v)
45
+ try:
46
+ with urllib.request.urlopen(req, timeout=30) as r:
47
+ return r.status, r.read()
48
+ except urllib.error.HTTPError as e:
49
+ return e.code, e.read()
50
+
51
+
52
+ def main():
53
+ print()
54
+ destino = tempfile.mkdtemp()
55
+ serve.DESTINO = os.path.join(destino, "ciudad")
56
+
57
+ servidor = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
58
+ puerto = servidor.server_port
59
+ hilo = threading.Thread(target=servidor.serve_forever, daemon=True)
60
+ hilo.start()
61
+
62
+ # The hall reads whatever city AGENTS_CITY_DATA points at; give it one of its
63
+ # own so this suite never touches the caller's, and an empty disk index so
64
+ # /api/misrepos cannot wander off crawling the real home directory.
65
+ hallDatos = tempfile.mkdtemp()
66
+ appDatos = tempfile.mkdtemp()
67
+ arbitraryDatos = tempfile.mkdtemp()
68
+ # The suite must never write into the caller's real city registry.
69
+ serve.cities.REGISTRO = os.path.join(tempfile.mkdtemp(), "cities")
70
+ os.environ["AGENTS_CITY_DATA"] = hallDatos
71
+ os.environ["AGENTS_CITY_HOME"] = appDatos
72
+ os.environ["AGENTS_CITY_USER"] = "halltest"
73
+ os.environ["CITY_SEARCH_IN"] = tempfile.mkdtemp()
74
+ os.environ["XDG_CACHE_HOME"] = tempfile.mkdtemp()
75
+
76
+ try:
77
+ # ── the doors that must be closed ──────────────────────────────────
78
+ print(" what must be refused")
79
+ st, _ = pide(puerto, "/", con_pase=False)
80
+ comprueba("· no token: nothing, not even the page", st, 403)
81
+ st, _ = pide(puerto, "/?PASE=wrong-token-entirely", con_pase=False)
82
+ comprueba("· a wrong token is the same as none", st, 403)
83
+ st, _ = pide(puerto, "/api/estado", con_pase=False)
84
+ comprueba("· the API refuses without it too", st, 403)
85
+ st, _ = pide(puerto, "/api/ficha", con_pase=False, metodo="POST", cuerpo={})
86
+ comprueba("· and above all the endpoint that writes to disk", st, 403)
87
+ # This server writes files: a page from any other origin must not drive it.
88
+ st, _ = pide(puerto, "/api/estado", cabeceras={"Origin": "http://evil.example"})
89
+ comprueba("· a foreign Origin is refused even with the token", st, 403)
90
+ st, _ = pide(puerto, "/api/estado", cabeceras={"Origin": f"http://127.0.0.1:{puerto}"})
91
+ comprueba("· our own origin is not", st, 200)
92
+ st, _ = pide(puerto, "/api/no-such-thing")
93
+ comprueba("· an unknown route is a 404, not a stack trace", st, 404)
94
+ # The demo's remote control must never touch a real city: a replay on a
95
+ # real bus would be publishing fiction as committee history.
96
+ st, _ = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "restart"})
97
+ comprueba("· the demo replay refuses any city that is not the demo", st, 403)
98
+ st, cuerpo = pide(puerto, "/api/estado?city=" + urllib.parse.quote(arbitraryDatos, safe=""))
99
+ estado_seguro = json.loads(cuerpo)
100
+ afirma(
101
+ "· a crafted city path cannot turn an arbitrary folder into a city",
102
+ st == 200
103
+ and estado_seguro["datos"] == os.path.realpath(hallDatos)
104
+ and not os.path.exists(os.path.join(arbitraryDatos, "city.yml")),
105
+ )
106
+ st, cuerpo = pide(
107
+ puerto, "/api/roads", metodo="POST", cuerpo=None, cabeceras={"Content-Length": "0"}
108
+ )
109
+ afirma("· an empty POST body is handled, not a crash", st in (200, 400), f"status {st}")
110
+
111
+ # ── the happy path: the Hall and its role catalogue ─────────────────
112
+ print(" what must work")
113
+ st, cuerpo = pide(puerto, "/")
114
+ comprueba("· the page is served", st, 200)
115
+ afirma(
116
+ "· with the run token injected, not the placeholder",
117
+ serve.PASE.encode() in cuerpo and b"__PASE__" not in cuerpo,
118
+ )
119
+
120
+ st, cuerpo = pide(puerto, "/api/roles?kind=product")
121
+ rolesR = json.loads(cuerpo)["roles"]
122
+ comprueba("· the product city offers its ten roles, including blank", len(rolesR), 10)
123
+ afirma(
124
+ "· with the trade read from the role files",
125
+ any(r["trade"] == "Architect" for r in rolesR),
126
+ )
127
+ afirma(
128
+ "· blank is visible but carries no invented trade",
129
+ any(r["id"] == "blank" and r["trade"] == "No preset" for r in rolesR),
130
+ str(rolesR),
131
+ )
132
+ st, cuerpo = pide(puerto, "/api/domains")
133
+ dominios = json.loads(cuerpo)["domains"]
134
+ afirma(
135
+ "· the Hall gets the same domain-first catalogue as the terminal",
136
+ st == 200
137
+ and dominios[0]["id"] == "software"
138
+ and any(d["id"] == "healthcare" for d in dominios),
139
+ str(dominios),
140
+ )
141
+ roles_salud = json.loads(pide(puerto, "/api/roles?domain=healthcare")[1])["roles"]
142
+ afirma(
143
+ "· selecting medicine replaces the software role grid",
144
+ roles_salud[0]["id"] == "clinical-director"
145
+ and not any(r["id"] == "dev" for r in roles_salud),
146
+ str(roles_salud),
147
+ )
148
+ roles_agente = json.loads(pide(puerto, "/api/roles?scope=agent")[1])["roles"]
149
+ ids_agente = {r["id"] for r in roles_agente}
150
+ afirma(
151
+ "· a repo agent can adopt a role outside the city domain",
152
+ {"blank", "seo", "po", "data-engineer"} <= ids_agente
153
+ and any("marketing" in r["domains"] for r in roles_agente if r["id"] == "seo"),
154
+ str(roles_agente),
155
+ )
156
+
157
+ # ── the town hall ───────────────────────────────────────────────────
158
+ print(" the town hall")
159
+ st, cuerpo = pide(puerto, "/")
160
+ afirma(
161
+ "· / serves the hall, token injected",
162
+ st == 200 and b"town hall" in cuerpo and b"__PASE__" not in cuerpo,
163
+ )
164
+ afirma(
165
+ "· the spectator panel is a moderated conversation with avatars and quiet work",
166
+ b"A moderated conversation over the WebSocket bus" in cuerpo
167
+ and b'id="liveWorkToggle"' in cuerpo
168
+ and b'id="liveContext"' in cuerpo
169
+ and b".liveAvatar" in cuerpo,
170
+ cuerpo[:500].decode(errors="replace"),
171
+ )
172
+ map_html = open(os.path.join(RAIZ, "city", "web", "index.html")).read()
173
+ hall_source = open(os.path.join(RAIZ, "city", "web", "src", "hall.ts")).read()
174
+ speech_source = open(os.path.join(RAIZ, "city", "web", "src", "game-speech.ts")).read()
175
+ afirma(
176
+ "· the city owns the whole central canvas; persistent HUD stays in sidebars",
177
+ '<main aria-label="City map"></main>' in map_html
178
+ and map_html.index('id="cinta"') < map_html.index('<main aria-label="City map"')
179
+ and map_html.index('id="mandos"') < map_html.index('<main aria-label="City map"')
180
+ and map_html.index('id="peli"') < map_html.index('<main aria-label="City map"')
181
+ and map_html.index('id="ficha"') > map_html.index('<aside id="avisos"')
182
+ and "#cinta{position:absolute" not in map_html
183
+ and "#peli{position:absolute" not in map_html,
184
+ )
185
+ afirma(
186
+ "· the Hall embeds only the canvas and forwards the same live WebSocket event",
187
+ "let SECCION = 'mapa'" in hall_source
188
+ and "mapUrl.searchParams.set('embed', '1')" in hall_source
189
+ and "mapUrl.searchParams.set('parent_origin', location.origin)" in hall_source
190
+ and "MAP_ACTIVITY_PROTOCOL" in hall_source
191
+ and "enviaActividadAlMapa(event)" in hall_source,
192
+ )
193
+ afirma(
194
+ "· game speech is anchored to a real actor and starts with its recipient",
195
+ "speech.target.getBounds()" in speech_source
196
+ and "`Para ${speechRecipient(event)}:`" in speech_source
197
+ and "compactSpeech(event.summary)" in speech_source,
198
+ )
199
+ st, cuerpo = pide(puerto, "/hall.js")
200
+ if st == 200:
201
+ afirma(
202
+ "· the hall code is served, typed and built",
203
+ b"town hall" in cuerpo or len(cuerpo) > 1000,
204
+ )
205
+ else:
206
+ afirma(
207
+ "· an unbuilt hall says how to build it, not just 404",
208
+ st == 404 and b"npm" in cuerpo,
209
+ f"{st}: {cuerpo[:120]!r}",
210
+ )
211
+ # And the unbuilt branch, deterministically.
212
+ js_real = serve.HALL_JS
213
+ serve.HALL_JS = "/definitely/not/built/hall.js"
214
+ st, cuerpo = pide(puerto, "/hall.js")
215
+ afirma(
216
+ "· missing build is a 404 with the build command",
217
+ st == 404 and b"npm run build" in cuerpo,
218
+ f"{st}: {cuerpo[:120]!r}",
219
+ )
220
+ serve.HALL_JS = js_real
221
+
222
+ st, cuerpo = pide(puerto, "/wizard")
223
+ afirma("· the v1 multiple-person wizard is retired", st == 404 and b"retired" in cuerpo)
224
+ st, _ = pide(puerto, "/api/escribe", metodo="POST", cuerpo={})
225
+ comprueba("· its multiple-person write endpoint is retired too", st, 404)
226
+
227
+ st, cuerpo = pide(puerto, "/api/estado")
228
+ estado = json.loads(cuerpo)
229
+ comprueba("· estado answers for an empty city", st, 200)
230
+ comprueba(
231
+ "· with no cards, no houses, no districts",
232
+ (estado["tarjetas"], estado["parcelas"], estado["unidades"]),
233
+ ([], [], []),
234
+ )
235
+ comprueba("· and no invented committee history", estado["deliberations"], [])
236
+ afirma("· and it knows who is asking", bool(estado["yo"]), repr(estado["yo"]))
237
+ owner, address = estado["yo"], estado["address"]
238
+ afirma(
239
+ "· its public road invitation contains no credential",
240
+ estado["invitation"]["address"] == address
241
+ and estado["invitation"]["domain"] == "software"
242
+ and not any("token" in k.lower() for k in estado["invitation"]),
243
+ )
244
+
245
+ # Committee acts are read-only local state in the Hall.
246
+ act_id = "delib_20260826120000_abcd1234"
247
+ act_dir = os.path.join(hallDatos, "deliberations", act_id)
248
+ os.makedirs(act_dir)
249
+ open(os.path.join(act_dir, "ACT.md"), "w").write("# Ship?\n")
250
+ open(os.path.join(act_dir, "state.json"), "w").write(
251
+ json.dumps(
252
+ {
253
+ "schema": "agents-city/deliberation@1",
254
+ "id": act_id,
255
+ "status": "verifying",
256
+ "updatedAt": "2026-08-26T12:00:00Z",
257
+ "brief": {
258
+ "question": "Ship?",
259
+ "desiredOutcome": "A verified answer",
260
+ "participants": ["api", "web"],
261
+ },
262
+ "positions": {"api": {}},
263
+ "progress": {"revision": 2},
264
+ "decisions": [
265
+ {
266
+ "outcome": "Ship after replay",
267
+ "verifier": "ops",
268
+ "decisiveContributors": ["api"],
269
+ }
270
+ ],
271
+ }
272
+ )
273
+ )
274
+ acts = json.loads(pide(puerto, "/api/estado")[1])["deliberations"]
275
+ afirma(
276
+ "· the Hall shows durable committee progress without mutating it",
277
+ len(acts) == 1
278
+ and acts[0]["id"] == act_id
279
+ and acts[0]["received"] == 1
280
+ and acts[0]["verifier"] == "ops"
281
+ and acts[0]["contributors"] == ["api"],
282
+ str(acts),
283
+ )
284
+
285
+ # A seat written through the hall is the same card as everywhere else.
286
+ st, cuerpo = pide(
287
+ puerto,
288
+ "/api/ficha",
289
+ metodo="POST",
290
+ cuerpo={
291
+ "user": "Ana.López@x.com",
292
+ "domain": "healthcare",
293
+ "role": "clinical-director",
294
+ "repos": ["api"],
295
+ "repo_roles": {"api": "seo"},
296
+ "objetivo": {
297
+ "title": "One goal",
298
+ "signal": "s",
299
+ "command": "",
300
+ "baseline": "0",
301
+ "target": "1",
302
+ "by": "Q3",
303
+ },
304
+ },
305
+ )
306
+ r = json.loads(cuerpo)
307
+ comprueba("· a card is written through the hall", st, 200)
308
+ comprueba("· body.user cannot create a second person in the city", r["user"], owner)
309
+ c = card.lee(os.path.join(hallDatos, f"{owner}.md"))
310
+ comprueba(
311
+ "· and reads back through the shared reader",
312
+ (c["agent"], c["role"], c["repos"], c["repo_roles"]),
313
+ (address, "clinical-director", ["api"], {"api": "seo"}),
314
+ )
315
+ estado_salud = json.loads(pide(puerto, "/api/estado")[1])
316
+ afirma(
317
+ "· domain and role knowledge land transparently in the city",
318
+ estado_salud["domain"] == "healthcare"
319
+ and os.path.isfile(os.path.join(hallDatos, "domains", "healthcare.md"))
320
+ and os.path.isfile(os.path.join(hallDatos, "roles", "clinical-director.md"))
321
+ and os.path.isfile(os.path.join(hallDatos, "roles", "seo.md")),
322
+ )
323
+ afirma(
324
+ "· the live capability view exposes the repo agent role",
325
+ estado_salud["skills"]["api"]["role"] == "seo",
326
+ str(estado_salud["skills"]),
327
+ )
328
+ comprueba(
329
+ "· roads can route by public domain and seat role",
330
+ (estado_salud["invitation"]["domain"], estado_salud["invitation"]["role"]),
331
+ ("healthcare", "clinical-director"),
332
+ )
333
+ comprueba(
334
+ "· an empty command is manual, not an empty string to run", c["objetivo"]["command"], ""
335
+ )
336
+
337
+ st, cuerpo = pide(
338
+ puerto,
339
+ "/api/ficha",
340
+ metodo="POST",
341
+ cuerpo={
342
+ "domain": "healthcare",
343
+ "role": "clinical-director",
344
+ "repos": ["api"],
345
+ "repo_roles": [],
346
+ },
347
+ )
348
+ afirma(
349
+ "· malformed repo-role mappings are a visible 400",
350
+ st == 400 and "repo_roles must be an object" in json.loads(cuerpo)["error"],
351
+ cuerpo.decode(),
352
+ )
353
+
354
+ # A goal with no title is no goal, not a goal called nothing.
355
+ pide(
356
+ puerto,
357
+ "/api/ficha",
358
+ metodo="POST",
359
+ cuerpo={
360
+ "user": "bo@x.com",
361
+ "domain": "healthcare",
362
+ "role": "clinical-director",
363
+ "repos": [],
364
+ "objetivo": {"title": " "},
365
+ },
366
+ )
367
+ comprueba(
368
+ "· a blank goal title means no goal",
369
+ card.lee(os.path.join(hallDatos, f"{owner}.md"))["objetivo"],
370
+ None,
371
+ )
372
+
373
+ # Districts: the wrong shape is a 400 the page can show, the right one
374
+ # round-trips through the shared reader.
375
+ st, _ = pide(puerto, "/api/unidades", metodo="POST", cuerpo={"unidades": "banking"})
376
+ comprueba("· districts as a string is a 400, not a crash", st, 400)
377
+ st, _ = pide(
378
+ puerto, "/api/unidades", metodo="POST", cuerpo={"unidades": [{"name": "no id"}]}
379
+ )
380
+ comprueba("· a district with no id, likewise", st, 400)
381
+ st, cuerpo = pide(
382
+ puerto,
383
+ "/api/unidades",
384
+ metodo="POST",
385
+ cuerpo={"unidades": [{"id": "Banca Digital", "name": "Banca", "color": "#3FB8A0"}]},
386
+ )
387
+ r = json.loads(cuerpo)
388
+ comprueba("· a real district is written and cleaned", st, 200)
389
+ comprueba(
390
+ "· id slugged, colour lowered",
391
+ (r["unidades"][0]["id"], r["unidades"][0]["color"]),
392
+ ("banca-digital", "3fb8a0"),
393
+ )
394
+
395
+ # Houses: same deal.
396
+ st, _ = pide(puerto, "/api/parcelas", metodo="POST", cuerpo={"repos": ["x"]})
397
+ comprueba("· houses as a list is a 400", st, 400)
398
+ st, cuerpo = pide(
399
+ puerto,
400
+ "/api/parcelas",
401
+ metodo="POST",
402
+ cuerpo={
403
+ "repos": {
404
+ "api": [
405
+ {"ruta": "src/**", "unidad": "banca-digital", "nombre": "api · src"},
406
+ {"ruta": "", "unidad": "none", "nombre": "api · rest"},
407
+ ]
408
+ },
409
+ "lab": ["lab-x"],
410
+ },
411
+ )
412
+ r = json.loads(cuerpo)
413
+ comprueba(
414
+ "· a split repo round-trips through the shared reader",
415
+ [(p["id"], p["unidad"]) for p in r["parcelas"]],
416
+ [("api:src/**", "banca-digital"), ("api", "none")],
417
+ )
418
+ # And estado now reflects all of it — the page repaints from this.
419
+ estado = json.loads(pide(puerto, "/api/estado")[1])
420
+ comprueba(
421
+ "· estado sees one seat, two houses, one district",
422
+ (
423
+ len(estado["tarjetas"]),
424
+ len(estado["parcelas"]),
425
+ len(estado["unidades"]),
426
+ estado["lab"],
427
+ ),
428
+ (1, 2, 1, ["lab-x"]),
429
+ )
430
+
431
+ # ── several cities and their roads through one hall ────────────────
432
+ st, cuerpo = pide(puerto, "/api/ciudades", metodo="POST", cuerpo={"name": "Otra"})
433
+ creada = json.loads(cuerpo)
434
+ afirma(
435
+ "· the hall creates another city below the same user",
436
+ st == 200 and creada.get("address") == "halltest/otra",
437
+ str(creada),
438
+ )
439
+ otra = creada["city"]
440
+ est1 = json.loads(pide(puerto, "/api/estado")[1])
441
+ est2 = json.loads(pide(puerto, "/api/estado?city=" + otra)[1])
442
+ comprueba(
443
+ "· ?city= switches which city the hall manages",
444
+ (est1["datos"] != est2["datos"], os.path.realpath(otra) == est2["datos"]),
445
+ (True, True),
446
+ )
447
+ afirma(
448
+ "· the session name follows the city, so attach commands are right",
449
+ est2["sesion"].endswith("-otra") and not est1["sesion"].endswith("-otra"),
450
+ f"{est1['sesion']} / {est2['sesion']}",
451
+ )
452
+ afirma(
453
+ "· and the switcher gets the list with the current one marked",
454
+ any(c["actual"] for c in est2["ciudades"]),
455
+ )
456
+ st, cuerpo = pide(
457
+ puerto,
458
+ "/api/roads",
459
+ metodo="POST",
460
+ cuerpo={"action": "connect", "target": est2["city_id"]},
461
+ )
462
+ r = json.loads(cuerpo)
463
+ afirma(
464
+ "· opening a local road writes the current endpoint",
465
+ st == 200 and r["roads"][0]["address"] == "halltest/otra",
466
+ str(r),
467
+ )
468
+ otra_estado = json.loads(pide(puerto, "/api/estado?city=" + otra)[1])
469
+ afirma(
470
+ "· and the other endpoint is symmetric",
471
+ otra_estado["roads"][0]["id"] == est1["city_id"],
472
+ str(otra_estado["roads"]),
473
+ )
474
+ st, cuerpo = pide(
475
+ puerto,
476
+ "/api/roads",
477
+ metodo="POST",
478
+ cuerpo={"action": "disconnect", "target": est2["city_id"]},
479
+ )
480
+ afirma(
481
+ "· closing it removes both endpoints",
482
+ st == 200
483
+ and json.loads(cuerpo)["roads"] == []
484
+ and json.loads(pide(puerto, "/api/estado?city=" + otra)[1])["roads"] == [],
485
+ )
486
+ # A write follows the same request's city — never the default's.
487
+ pide(
488
+ puerto,
489
+ "/api/unidades?city=" + otra,
490
+ metodo="POST",
491
+ cuerpo={"unidades": [{"id": "u1", "name": "U1", "color": "aabbcc"}]},
492
+ )
493
+ afirma(
494
+ "· a save lands in the requested city, not the default",
495
+ os.path.exists(os.path.join(otra, "units.yml"))
496
+ and "u1" in open(os.path.join(otra, "units.yml")).read(),
497
+ )
498
+ afirma(
499
+ "· and the default city was not touched by it",
500
+ not os.path.exists(os.path.join(hallDatos, "units.yml"))
501
+ or "u1" not in open(os.path.join(hallDatos, "units.yml")).read(),
502
+ )
503
+ shutil.rmtree(otra)
504
+
505
+ # ── the demo's remote control ────────────────────────────────────────
506
+ print(" the demo's remote control")
507
+ # Which story a demo replays comes from its city.yml domain AS WRITTEN.
508
+ # The registry normalises unknown domains to software, which is exactly
509
+ # how the clinic once replayed Aurora's night instead of its own
510
+ # morning — this is that bug's tombstone.
511
+ comprueba(
512
+ "· each packaged demo city replays its own domain's story",
513
+ [
514
+ serve.historia_del_demo(os.path.join(RAIZ, "demo", d))
515
+ for d in ("city", "clinica", "despacho")
516
+ ],
517
+ ["software", "medicina", "legal"],
518
+ )
519
+ comprueba(
520
+ "· a demo with an unknown or missing domain falls back to software",
521
+ serve.historia_del_demo(tempfile.mkdtemp()),
522
+ "software",
523
+ )
524
+ # The control verbs, against a city dressed as a demo. `restart` is NOT
525
+ # exercised live on purpose: it spawns the real storyteller against the
526
+ # real bus; its story choice and its 403 on real cities are covered.
527
+ with open(os.path.join(hallDatos, "city.yml"), "w", encoding="utf-8") as f:
528
+ f.write("id: city_demo_test_v1\nname: Test Demo\nslug: test-demo\nowner: halltest\n")
529
+ st, _ = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "volar"})
530
+ comprueba("· an unknown control verb is a 400, not a surprise", st, 400)
531
+ st, cuerpo_demo = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "status"})
532
+ estado_demo = json.loads(cuerpo_demo)
533
+ afirma(
534
+ "· status answers honestly when nothing is playing",
535
+ st == 200 and estado_demo.get("ok") and not estado_demo.get("running"),
536
+ str(estado_demo),
537
+ )
538
+ st, cuerpo_demo = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "pause"})
539
+ estado_demo = json.loads(cuerpo_demo)
540
+ afirma(
541
+ "· pausing silence is a safe no-op, never a crash",
542
+ st == 200 and not estado_demo.get("running") and not estado_demo.get("paused"),
543
+ str(estado_demo),
544
+ )
545
+ os.unlink(os.path.join(hallDatos, "city.yml"))
546
+
547
+ # ── the agents' character sheets ─────────────────────────────────────
548
+ print(" the agents' character sheets")
549
+ # An agents-first card: a knowledge agent with its workspace on disk,
550
+ # so discovery, growth and the engine keys all have something real.
551
+ taller = os.path.join(hallDatos, "agents", "notas")
552
+ os.makedirs(taller, exist_ok=True)
553
+ open(os.path.join(taller, "apuntes.md"), "w").write("# notas\n")
554
+ with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
555
+ f.write(
556
+ "---\n"
557
+ "user: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
558
+ "agents: [notas]\nkind.notas: knowledge\nrole.notas: apuntes\n"
559
+ "runs.notas: claude\nmodel.notas: opus\neffort.notas: high\n"
560
+ "goals_defined: true\n---\n"
561
+ )
562
+ st, cuerpo = pide(puerto, "/api/estado")
563
+ estado = json.loads(cuerpo)
564
+ hoja = next((x for x in estado.get("agents", []) if x["name"] == "notas"), None)
565
+ afirma(
566
+ "· the sheet carries kind, engine and growth — all read, none invented",
567
+ st == 200
568
+ and hoja is not None
569
+ and hoja["kind"] == "knowledge"
570
+ and hoja["runtime"] == "claude"
571
+ and hoja["model"] == "opus"
572
+ and hoja["effort"] == "high"
573
+ and hoja["growth"]["floors"] >= 1,
574
+ str(hoja),
575
+ )
576
+ afirma(
577
+ "· an agents-first agent is discovered with a face and a skills entry",
578
+ "notas" in estado.get("avatars", {}) and "notas" in estado.get("skills", {}),
579
+ str(sorted(estado.get("skills", {}).keys())),
580
+ )
581
+ st, cuerpo = pide(
582
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": "haiku"}
583
+ )
584
+ respuesta = json.loads(cuerpo)
585
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
586
+ afirma(
587
+ "· happy: tuning the engine persists to the card key the launcher reads",
588
+ st == 200 and respuesta["agent"]["model"] == "haiku" and "model.notas: haiku" in carta,
589
+ carta,
590
+ )
591
+ st, cuerpo = pide(
592
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": ""}
593
+ )
594
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
595
+ afirma(
596
+ "· happy: an empty value removes the key — back to the owner's default",
597
+ st == 200 and "model.notas" not in carta,
598
+ carta,
599
+ )
600
+ st, _ = pide(
601
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "nadie", "model": "opus"}
602
+ )
603
+ comprueba("· non-happy: an unknown agent is a 404, never a write", st, 404)
604
+ st, _ = pide(
605
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "effort": "turbo"}
606
+ )
607
+ comprueba("· non-happy: an invented effort level is refused", st, 400)
608
+ st, _ = pide(
609
+ puerto,
610
+ "/api/agente",
611
+ metodo="POST",
612
+ cuerpo={"agent": "notas", "model": "opus; rm -rf /"},
613
+ )
614
+ comprueba("· non-happy: a model value is an alias, never a shell fragment", st, 400)
615
+ st, cuerpo = pide(
616
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "runtime": "codex"}
617
+ )
618
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
619
+ afirma(
620
+ "· happy: the provider is a card key too",
621
+ st == 200 and "runs.notas: codex" in carta,
622
+ carta,
623
+ )
624
+ st, _ = pide(
625
+ puerto,
626
+ "/api/agente",
627
+ metodo="POST",
628
+ cuerpo={"agent": "notas", "runtime": "terminal:sh"},
629
+ )
630
+ comprueba("· non-happy: the web never plants a terminal command", st, 400)
631
+ st, cuerpo = pide(
632
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": "abc123"}
633
+ )
634
+ cara_nueva = json.loads(cuerpo)["agent"]["avatar"]
635
+ st2, cuerpo2 = pide(
636
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": ""}
637
+ )
638
+ afirma(
639
+ "· happy: a face reroll is a seed on the card, and clearing it restores the classic",
640
+ st == 200
641
+ and st2 == 200
642
+ and cara_nueva != json.loads(cuerpo2)["agent"]["avatar"],
643
+ "",
644
+ )
645
+ st, _ = pide(
646
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": "MAL$SEED"}
647
+ )
648
+ comprueba("· non-happy: an avatar seed is short and plain", st, 400)
649
+ # A 400 must never be an answer that already mutated the card: the
650
+ # handler once wrote field-by-field, so a valid model landed before an
651
+ # invalid effort was refused.
652
+ pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": "sonnet"})
653
+ st, _ = pide(
654
+ puerto,
655
+ "/api/agente",
656
+ metodo="POST",
657
+ cuerpo={"agent": "notas", "model": "haiku", "effort": "turbo"},
658
+ )
659
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
660
+ afirma(
661
+ "· non-happy: a refused edit leaves the whole card untouched",
662
+ st == 400 and "model.notas: sonnet" in carta and "haiku" not in carta,
663
+ carta,
664
+ )
665
+ pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": ""})
666
+
667
+ # ── instructions and skills, into the agent's own home ──────────────
668
+ print(" instructions and skills, written only into the agent's own home")
669
+ st, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
670
+ antes = json.loads(cuerpo)
671
+ afirma(
672
+ "· happy: a missing instruction file reads honestly as absent, with its reader",
673
+ st == 200 and antes["exists"] is False and antes["reader"] == "claude",
674
+ str(antes),
675
+ )
676
+ st, _ = pide(
677
+ puerto,
678
+ "/api/instrucciones",
679
+ metodo="POST",
680
+ cuerpo={"agent": "notas", "file": "CLAUDE.md", "content": "# Notas\nSé breve.\n"},
681
+ )
682
+ st2, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
683
+ despues = json.loads(cuerpo)
684
+ afirma(
685
+ "· happy: written atomically into the workspace and read back verbatim",
686
+ st == 200 and st2 == 200 and despues["exists"] and "Sé breve." in despues["content"],
687
+ str(despues),
688
+ )
689
+ st, _ = pide(puerto, "/api/instrucciones?agent=notas&file=HACK.md")
690
+ comprueba("· non-happy: only CLAUDE.md and AGENTS.md exist here", st, 400)
691
+ # A latin-1 CLAUDE.md once escaped the handler as a UnicodeDecodeError
692
+ # and dropped the connection; and `exists: false` would be worse — the
693
+ # editor would save over what it could not read.
694
+ with open(os.path.join(taller, "CLAUDE.md"), "wb") as f:
695
+ f.write("Triaje: café\n".encode("latin-1"))
696
+ st, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
697
+ afirma(
698
+ "· non-happy: a non-UTF-8 file is a JSON refusal, never a dropped connection",
699
+ st == 409 and "not UTF-8" in json.loads(cuerpo).get("error", ""),
700
+ cuerpo.decode(errors="replace"),
701
+ )
702
+ pide(
703
+ puerto,
704
+ "/api/instrucciones",
705
+ metodo="POST",
706
+ cuerpo={"agent": "notas", "file": "CLAUDE.md", "content": "# Notas\nSé breve.\n"},
707
+ )
708
+ st, _ = pide(
709
+ puerto,
710
+ "/api/instrucciones",
711
+ metodo="POST",
712
+ cuerpo={"agent": "nadie", "file": "CLAUDE.md", "content": "x"},
713
+ )
714
+ comprueba("· non-happy: an unknown agent gets no file", st, 404)
715
+
716
+ import base64 as _b64
717
+ import io as _io
718
+ import zipfile as _zip
719
+
720
+ def zipea(entradas, enlace=None):
721
+ crudo = _io.BytesIO()
722
+ with _zip.ZipFile(crudo, "w") as z:
723
+ for nombre, contenido in entradas:
724
+ z.writestr(nombre, contenido)
725
+ if enlace:
726
+ info = _zip.ZipInfo(enlace)
727
+ info.external_attr = (0o120777 << 16)
728
+ z.writestr(info, "/etc/passwd")
729
+ return _b64.b64encode(crudo.getvalue()).decode()
730
+
731
+ bueno = zipea([("mi-skill/SKILL.md", "---\nname: mi-skill\n---\nhola"),
732
+ ("mi-skill/extra.md", "más")])
733
+ st, cuerpo = pide(
734
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "zip": bueno}
735
+ )
736
+ st2, cuerpo2 = pide(puerto, "/api/estado")
737
+ skills_notas = json.loads(cuerpo2)["skills"].get("notas", {}).get("skills", [])
738
+ afirma(
739
+ "· happy: an uploaded skill lands in the agent's home and is discovered live",
740
+ st == 200 and any(s["name"] == "mi-skill" for s in skills_notas),
741
+ str(skills_notas),
742
+ )
743
+ st, _ = pide(
744
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "zip": bueno}
745
+ )
746
+ comprueba("· non-happy: installing over an existing skill is refused", st, 409)
747
+ st, _ = pide(
748
+ puerto,
749
+ "/api/skill",
750
+ metodo="POST",
751
+ cuerpo={"agent": "notas", "zip": zipea([("../fuera/SKILL.md", "x")])},
752
+ )
753
+ comprueba("· non-happy: a zip that tries to escape its folder is refused", st, 400)
754
+ st, _ = pide(
755
+ puerto,
756
+ "/api/skill",
757
+ metodo="POST",
758
+ cuerpo={
759
+ "agent": "notas",
760
+ "zip": zipea([("liada/SKILL.md", "x")], enlace="liada/mala"),
761
+ },
762
+ )
763
+ comprueba("· non-happy: symlinks inside a skill are refused", st, 400)
764
+ st, _ = pide(
765
+ puerto,
766
+ "/api/skill",
767
+ metodo="POST",
768
+ cuerpo={"agent": "notas", "name": "sin-manifiesto", "zip": zipea([("leeme.md", "x")])},
769
+ )
770
+ comprueba("· non-happy: a skill is a folder with a SKILL.md, or nothing", st, 400)
771
+ st, _ = pide(
772
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "mi-skill"}
773
+ )
774
+ st2, cuerpo2 = pide(puerto, "/api/estado")
775
+ skills_notas = json.loads(cuerpo2)["skills"].get("notas", {}).get("skills", [])
776
+ afirma(
777
+ "· happy: removing an installed skill empties exactly its folder",
778
+ st == 200 and not any(s["name"] == "mi-skill" for s in skills_notas),
779
+ str(skills_notas),
780
+ )
781
+ st, _ = pide(
782
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "fantasma"}
783
+ )
784
+ comprueba("· non-happy: removing a skill that is not there is a 404", st, 404)
785
+
786
+ # The 14 MB cap bounds the upload; only the extraction budget bounds
787
+ # what a hostile deflate ratio inflates it to on disk. 80 MB of zeros
788
+ # deflates to under 100 KB — small on the wire, huge when it lands.
789
+ crudo_bomba = _io.BytesIO()
790
+ with _zip.ZipFile(crudo_bomba, "w", _zip.ZIP_DEFLATED) as z:
791
+ z.writestr("boom/SKILL.md", "---\nname: boom\n---\n")
792
+ z.writestr("boom/ceros.bin", b"\0" * (80 * 1024 * 1024))
793
+ st, cuerpo = pide(
794
+ puerto,
795
+ "/api/skill",
796
+ metodo="POST",
797
+ cuerpo={
798
+ "agent": "notas",
799
+ "zip": _b64.b64encode(crudo_bomba.getvalue()).decode(),
800
+ },
801
+ )
802
+ afirma(
803
+ "· non-happy: a zip bomb is refused by its decompressed size, not its upload size",
804
+ st == 400 and "inflates too large" in json.loads(cuerpo).get("error", ""),
805
+ cuerpo.decode(errors="replace"),
806
+ )
807
+ # A zip whose entries collide (a file, then a directory of the same
808
+ # name) once killed the handler mid-write and its debris 409'd every
809
+ # retry until deleted by hand.
810
+ st, _ = pide(
811
+ puerto,
812
+ "/api/skill",
813
+ metodo="POST",
814
+ cuerpo={
815
+ "agent": "notas",
816
+ "zip": zipea([("s/SKILL.md", "---\nname: s\n---\n"), ("s/f", "x"), ("s/f/g", "y")]),
817
+ },
818
+ )
819
+ st2, _ = pide(
820
+ puerto,
821
+ "/api/skill",
822
+ metodo="POST",
823
+ cuerpo={"agent": "notas", "zip": zipea([("s/SKILL.md", "---\nname: s\n---\n")])},
824
+ )
825
+ afirma(
826
+ "· non-happy: a colliding zip is a 400 that cleans up — the retry installs fine",
827
+ st == 400 and st2 == 200,
828
+ f"{st}/{st2}",
829
+ )
830
+ pide(puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "s"})
831
+ # Removal is keyed on the FOLDER, and the sheet is told which skills the
832
+ # Hall itself could remove: a SKILL.md display name is free to differ,
833
+ # and a repo's committed skill is the repo's property — no × for it.
834
+ pide(
835
+ puerto,
836
+ "/api/skill",
837
+ metodo="POST",
838
+ cuerpo={
839
+ "agent": "notas",
840
+ "zip": zipea([("deploy-tools/SKILL.md", "---\nname: deploy\n---\n")]),
841
+ },
842
+ )
843
+ with open(os.path.join(taller, "SKILL.md"), "w", encoding="utf-8") as f:
844
+ f.write("---\nname: del-repo\n---\n")
845
+ st, cuerpo = pide(puerto, "/api/estado")
846
+ skills_notas = json.loads(cuerpo)["skills"].get("notas", {}).get("skills", [])
847
+ subida = next((s for s in skills_notas if s["name"] == "deploy"), None)
848
+ del_repo = next((s for s in skills_notas if s["name"] == "del-repo"), None)
849
+ afirma(
850
+ "· happy: an installed skill is marked removable, keyed on its folder",
851
+ subida is not None
852
+ and subida.get("removable") is True
853
+ and subida.get("dir") == "deploy-tools",
854
+ str(subida),
855
+ )
856
+ afirma(
857
+ "· a skill committed elsewhere in the home is discovered but never removable",
858
+ del_repo is not None and not del_repo.get("removable"),
859
+ str(del_repo),
860
+ )
861
+ st, _ = pide(
862
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "deploy"}
863
+ )
864
+ comprueba("· non-happy: the display name is not the folder — a 404, never a guess", st, 404)
865
+ st, _ = pide(
866
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "deploy-tools"}
867
+ )
868
+ comprueba("· happy: removal by folder name removes exactly that folder", st, 200)
869
+ os.unlink(os.path.join(taller, "SKILL.md"))
870
+ # A repo can commit `.claude/skills` as a symlink to anywhere this user
871
+ # can write — the global skills folder included. Both the install and
872
+ # the rmtree once followed it out of the agent's home.
873
+ raiz_skills = os.path.join(taller, ".claude", "skills")
874
+ shutil.rmtree(raiz_skills, ignore_errors=True)
875
+ fuera_de_casa = tempfile.mkdtemp()
876
+ os.symlink(fuera_de_casa, raiz_skills)
877
+ st, _ = pide(
878
+ puerto,
879
+ "/api/skill",
880
+ metodo="POST",
881
+ cuerpo={"agent": "notas", "zip": zipea([("fuga/SKILL.md", "---\nname: fuga\n---\n")])},
882
+ )
883
+ st2, _ = pide(
884
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "fuga"}
885
+ )
886
+ afirma(
887
+ "· non-happy: a symlinked .claude/skills is refused for install and removal alike",
888
+ st == 409 and st2 == 409 and not os.listdir(fuera_de_casa),
889
+ f"{st}/{st2}/{os.listdir(fuera_de_casa)}",
890
+ )
891
+ os.unlink(raiz_skills)
892
+ shutil.rmtree(fuera_de_casa, ignore_errors=True)
893
+
894
+ # ── the engine's traffic light and its test button ───────────────────
895
+ print(" the engine's traffic light, derived and never guessed")
896
+ # A fake binary makes the light deterministic on any machine, CI included.
897
+ cajon = tempfile.mkdtemp()
898
+ falso = os.path.join(cajon, "kimi")
899
+ with open(falso, "w") as f:
900
+ f.write('#!/bin/sh\necho "kimi 9.9.9"\n')
901
+ os.chmod(falso, 0o755)
902
+ ruta_previa = os.environ["PATH"]
903
+ os.environ["PATH"] = cajon + os.pathsep + ruta_previa
904
+ try:
905
+ pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "runtime": "kimi"})
906
+ st, cuerpo = pide(puerto, "/api/estado")
907
+ hoja = next(x for x in json.loads(cuerpo)["agents"] if x["name"] == "notas")
908
+ afirma(
909
+ "· yellow: the binary exists and the agent's window is not running",
910
+ st == 200
911
+ and hoja["cli"]["binary"] == "kimi"
912
+ and hoja["cli"]["installed"] is True
913
+ and hoja["cli"]["connected"] is False,
914
+ str(hoja["cli"]),
915
+ )
916
+ st, cuerpo = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "notas"})
917
+ prueba = json.loads(cuerpo)
918
+ afirma(
919
+ "· happy: the test button runs the real binary and reports its version",
920
+ st == 200 and prueba["ok"] is True and "9.9.9" in prueba.get("version", ""),
921
+ str(prueba),
922
+ )
923
+ os.environ["PATH"] = cajon # only the fake folder: kimi gone nowhere, others missing
924
+ os.remove(falso)
925
+ st, cuerpo = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "notas"})
926
+ prueba = json.loads(cuerpo)
927
+ afirma(
928
+ "· red: a missing binary is an honest answer, not an HTTP error",
929
+ st == 200 and prueba["ok"] is False and "not on this machine" in prueba["detail"],
930
+ str(prueba),
931
+ )
932
+ st, _ = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "nadie"})
933
+ comprueba("· non-happy: testing an unknown agent is a 404", st, 404)
934
+ finally:
935
+ os.environ["PATH"] = ruta_previa
936
+ shutil.rmtree(cajon, ignore_errors=True)
937
+
938
+ # ── regressions pinned by review ─────────────────────────────────────
939
+ print(" review regressions, pinned")
940
+ # tmux prefix-matches session names: bare `-t home` also matches
941
+ # `home-2`, lighting the green dot from a different city's windows.
942
+ fuente = open(os.path.join(AQUI, "serve.py"), encoding="utf-8").read()
943
+ afirma(
944
+ "· ventanas_vivas asks tmux for an exact session match",
945
+ '"=" + cities.sesion(owner, datos)' in fuente,
946
+ )
947
+ # An 80-char slug is a legal window name; a 64-char cap on the resolver
948
+ # made every sheet action 404 while the sheet itself rendered fine.
949
+ largo = "l" * 70
950
+ with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
951
+ f.write(
952
+ "---\n"
953
+ "user: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
954
+ f"agents: [notas, {largo}]\nkind.notas: knowledge\n"
955
+ "goals_defined: true\n---\n"
956
+ )
957
+ st, cuerpo = pide(
958
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": largo, "model": "haiku"}
959
+ )
960
+ afirma(
961
+ "· a 65-80 char slug can use its sheet's actions, not only render it",
962
+ st == 200 and json.loads(cuerpo)["agent"]["slug"] == largo,
963
+ cuerpo.decode(errors="replace")[:200],
964
+ )
965
+ # Two names that slug to one identity: the card tools raise loudly;
966
+ # the Hall and read-only discovery must degrade, never traceback.
967
+ import capabilities as _cap
968
+
969
+ with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
970
+ f.write(
971
+ "---\nuser: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
972
+ "agents: [store-service, store_service]\ngoals_defined: true\n---\n"
973
+ )
974
+ comprueba(
975
+ "· skills discovery over a slug-colliding card lists nothing, crashes nothing",
976
+ _cap.descubre_ciudad(hallDatos),
977
+ {},
978
+ )
979
+ st, cuerpo = pide(puerto, "/api/estado")
980
+ afirma(
981
+ "· /api/estado still answers over a slug-colliding card",
982
+ st == 200 and json.loads(cuerpo).get("agents") == [],
983
+ cuerpo.decode(errors="replace")[:200],
984
+ )
985
+ # The Hall binds loopback without the stdlib's socket.getfqdn() call:
986
+ # a reverse DNS lookup for a cosmetic name, which on an unanswered
987
+ # resolver stalls the server's start for tens of seconds.
988
+ import socket as _socket
989
+ import threading as _th
990
+ import time as _time
991
+
992
+ _fqdn = _socket.getfqdn
993
+ _socket.getfqdn = lambda *a: _time.sleep(60) or "nunca"
994
+ otro = {}
995
+ try:
996
+ atado = _th.Event()
997
+
998
+ def _ata():
999
+ otro["s"] = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
1000
+ atado.set()
1001
+
1002
+ _th.Thread(target=_ata, daemon=True).start()
1003
+ afirma("· the Hall binds without waiting on reverse DNS", atado.wait(timeout=5))
1004
+ finally:
1005
+ _socket.getfqdn = _fqdn
1006
+ if otro.get("s"):
1007
+ otro["s"].server_close()
1008
+
1009
+ # A code agent's growth reads real local git history — merge commits
1010
+ # as floors — instead of answering zero for lack of a counter.
1011
+ import subprocess as _sp
1012
+ import types as _types
1013
+
1014
+ repo = tempfile.mkdtemp()
1015
+ for orden in (
1016
+ ["git", "init", "-q"],
1017
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t",
1018
+ "commit", "--allow-empty", "-q", "-m", "uno"],
1019
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t",
1020
+ "commit", "--allow-empty", "-q", "-m", "dos"],
1021
+ ):
1022
+ _sp.run(orden, cwd=repo, capture_output=True)
1023
+ obrero = _types.SimpleNamespace(
1024
+ nombre="obrero", slug="obrero", legacy=False, workspace=repo, mounts=[]
1025
+ )
1026
+ prs, commits, act = serve.contador_git_local(obrero, hallDatos)
1027
+ afirma(
1028
+ "· the sheet's git counter reads real local history, no network",
1029
+ prs == 0 and commits == 2 and act == 2,
1030
+ f"{prs}/{commits}/{act}",
1031
+ )
1032
+ shutil.rmtree(repo, ignore_errors=True)
1033
+
1034
+ # /api/sesion is NOT exercised live on purpose: it spawns tmux windows
1035
+ # with real agents in them. Its refusal without the token is covered by
1036
+ # the blanket auth checks above, which is the part that must hold.
1037
+ finally:
1038
+ for k in (
1039
+ "AGENTS_CITY_DATA",
1040
+ "AGENTS_CITY_HOME",
1041
+ "AGENTS_CITY_USER",
1042
+ "CITY_SEARCH_IN",
1043
+ "XDG_CACHE_HOME",
1044
+ ):
1045
+ os.environ.pop(k, None)
1046
+ shutil.rmtree(hallDatos, ignore_errors=True)
1047
+ shutil.rmtree(appDatos, ignore_errors=True)
1048
+ shutil.rmtree(arbitraryDatos, ignore_errors=True)
1049
+ servidor.shutdown()
1050
+ servidor.server_close()
1051
+ shutil.rmtree(destino, ignore_errors=True)
1052
+
1053
+ return resumen("serve")
1054
+
1055
+
1056
+ if __name__ == "__main__":
1057
+ sys.exit(main())