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,1251 @@
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
+ # Two skins, one set of names. A colour written straight into a rule is
173
+ # a colour that exists in one theme only — the classic unreadable-page
174
+ # bug — so the palette is asserted to be the only place naming one.
175
+ pagina = cuerpo.decode(errors="replace")
176
+ cabecera = pagina.split("*{box-sizing", 1)[0]
177
+ cuerpo_css = pagina.split("*{box-sizing", 1)[1].split("</style>", 1)[0]
178
+ afirma(
179
+ "· the day palette carries the tokens of agentscity.net",
180
+ "--dia-suelo:#f3f0e8" in cabecera
181
+ and "--dia-tinta:#1d2229" in cabecera
182
+ and "--dia-lampara:#7048e8" in cabecera,
183
+ cabecera[-400:],
184
+ )
185
+ afirma(
186
+ "· an explicit choice outranks the machine's preference",
187
+ '[data-tema="claro"]' in cabecera
188
+ and "prefers-color-scheme:light" in cabecera
189
+ and ':root:not([data-tema="oscuro"])' in cabecera,
190
+ cabecera[-600:],
191
+ )
192
+ import re as _re
193
+
194
+ sueltos = [
195
+ c for c in _re.findall(r"#[0-9a-fA-F]{6}\b", cuerpo_css)
196
+ # The kind chips are brand identity, the same hue in both skins.
197
+ if c.lower() not in ("#3b82f6", "#8b5cf6", "#f59e0b", "#0f172a", "#f4efe5")
198
+ ]
199
+ afirma(
200
+ "· and no rule outside the palette hardcodes a colour of its own",
201
+ not sueltos, ", ".join(sorted(set(sueltos))),
202
+ )
203
+ afirma(
204
+ "· the switch is in the page, and says which skin is showing",
205
+ 'id="temaBoton"' in pagina, "",
206
+ )
207
+
208
+ map_html = open(os.path.join(RAIZ, "city", "web", "index.html")).read()
209
+ hall_source = open(os.path.join(RAIZ, "city", "web", "src", "hall.ts")).read()
210
+ speech_source = open(os.path.join(RAIZ, "city", "web", "src", "game-speech.ts")).read()
211
+ afirma(
212
+ "· the city owns the whole central canvas; persistent HUD stays in sidebars",
213
+ '<main aria-label="City map"></main>' in map_html
214
+ and map_html.index('id="cinta"') < map_html.index('<main aria-label="City map"')
215
+ and map_html.index('id="mandos"') < map_html.index('<main aria-label="City map"')
216
+ and map_html.index('id="peli"') < map_html.index('<main aria-label="City map"')
217
+ and map_html.index('id="ficha"') > map_html.index('<aside id="avisos"')
218
+ and "#cinta{position:absolute" not in map_html
219
+ and "#peli{position:absolute" not in map_html,
220
+ )
221
+ afirma(
222
+ "· the Hall embeds only the canvas and forwards the same live WebSocket event",
223
+ "let SECCION = 'mapa'" in hall_source
224
+ and "mapUrl.searchParams.set('embed', '1')" in hall_source
225
+ and "mapUrl.searchParams.set('parent_origin', location.origin)" in hall_source
226
+ and "MAP_ACTIVITY_PROTOCOL" in hall_source
227
+ and "enviaActividadAlMapa(event)" in hall_source,
228
+ )
229
+ afirma(
230
+ "· game speech is anchored to a real actor and starts with its recipient",
231
+ "speech.target.getBounds()" in speech_source
232
+ and "`Para ${speechRecipient(event)}:`" in speech_source
233
+ and "compactSpeech(event.summary)" in speech_source,
234
+ )
235
+ st, cuerpo = pide(puerto, "/hall.js")
236
+ if st == 200:
237
+ afirma(
238
+ "· the hall code is served, typed and built",
239
+ b"town hall" in cuerpo or len(cuerpo) > 1000,
240
+ )
241
+ else:
242
+ afirma(
243
+ "· an unbuilt hall says how to build it, not just 404",
244
+ st == 404 and b"npm" in cuerpo,
245
+ f"{st}: {cuerpo[:120]!r}",
246
+ )
247
+ # And the unbuilt branch, deterministically.
248
+ js_real = serve.HALL_JS
249
+ serve.HALL_JS = "/definitely/not/built/hall.js"
250
+ st, cuerpo = pide(puerto, "/hall.js")
251
+ afirma(
252
+ "· missing build is a 404 with the build command",
253
+ st == 404 and b"npm run build" in cuerpo,
254
+ f"{st}: {cuerpo[:120]!r}",
255
+ )
256
+ serve.HALL_JS = js_real
257
+
258
+ st, cuerpo = pide(puerto, "/wizard")
259
+ afirma("· the v1 multiple-person wizard is retired", st == 404 and b"retired" in cuerpo)
260
+ st, _ = pide(puerto, "/api/escribe", metodo="POST", cuerpo={})
261
+ comprueba("· its multiple-person write endpoint is retired too", st, 404)
262
+
263
+ st, cuerpo = pide(puerto, "/api/estado")
264
+ estado = json.loads(cuerpo)
265
+ comprueba("· estado answers for an empty city", st, 200)
266
+ comprueba(
267
+ "· with no cards, no houses, no districts",
268
+ (estado["tarjetas"], estado["parcelas"], estado["unidades"]),
269
+ ([], [], []),
270
+ )
271
+ comprueba("· and no invented committee history", estado["deliberations"], [])
272
+ afirma("· and it knows who is asking", bool(estado["yo"]), repr(estado["yo"]))
273
+ owner, address = estado["yo"], estado["address"]
274
+ afirma(
275
+ "· its public road invitation contains no credential",
276
+ estado["invitation"]["address"] == address
277
+ and estado["invitation"]["domain"] == "software"
278
+ and not any("token" in k.lower() for k in estado["invitation"]),
279
+ )
280
+
281
+ # Committee acts are read-only local state in the Hall.
282
+ act_id = "delib_20260826120000_abcd1234"
283
+ act_dir = os.path.join(hallDatos, "deliberations", act_id)
284
+ os.makedirs(act_dir)
285
+ open(os.path.join(act_dir, "ACT.md"), "w").write("# Ship?\n")
286
+ open(os.path.join(act_dir, "state.json"), "w").write(
287
+ json.dumps(
288
+ {
289
+ "schema": "agents-city/deliberation@1",
290
+ "id": act_id,
291
+ "status": "verifying",
292
+ "updatedAt": "2026-08-26T12:00:00Z",
293
+ "brief": {
294
+ "question": "Ship?",
295
+ "desiredOutcome": "A verified answer",
296
+ "participants": ["api", "web"],
297
+ },
298
+ "positions": {"api": {}},
299
+ "progress": {"revision": 2},
300
+ "decisions": [
301
+ {
302
+ "outcome": "Ship after replay",
303
+ "verifier": "ops",
304
+ "decisiveContributors": ["api"],
305
+ }
306
+ ],
307
+ }
308
+ )
309
+ )
310
+ acts = json.loads(pide(puerto, "/api/estado")[1])["deliberations"]
311
+ afirma(
312
+ "· the Hall shows durable committee progress without mutating it",
313
+ len(acts) == 1
314
+ and acts[0]["id"] == act_id
315
+ and acts[0]["received"] == 1
316
+ and acts[0]["verifier"] == "ops"
317
+ and acts[0]["contributors"] == ["api"],
318
+ str(acts),
319
+ )
320
+
321
+ # A seat written through the hall is the same card as everywhere else.
322
+ # The roster is NOT part of it: agents are added through /api/agentes,
323
+ # and saving the seat must carry them over untouched — the whole point
324
+ # of having one writer per fact. The card comes first: an agent lives on
325
+ # its owner's card, so there is nothing to join until that exists.
326
+ st, cuerpo = pide(
327
+ puerto,
328
+ "/api/ficha",
329
+ metodo="POST",
330
+ cuerpo={
331
+ "user": "Ana.López@x.com",
332
+ "domain": "healthcare",
333
+ "role": "clinical-director",
334
+ "objetivo": {
335
+ "title": "One goal",
336
+ "signal": "s",
337
+ "command": "",
338
+ "baseline": "0",
339
+ "target": "1",
340
+ "by": "Q3",
341
+ },
342
+ },
343
+ )
344
+ r = json.loads(cuerpo)
345
+ comprueba("· a card is written through the hall", st, 200)
346
+ comprueba("· body.user cannot create a second person in the city", r["user"], owner)
347
+ st_alta, cuerpo_alta = pide(
348
+ puerto, "/api/agentes", metodo="POST",
349
+ cuerpo={"name": "api", "kind": "code", "role": "seo"},
350
+ )
351
+ comprueba("· an agent joins the city through its own endpoint", st_alta, 200)
352
+ # Saving the seat a second time is where the old shape would have
353
+ # silently replaced the roster with whatever the page happened to send.
354
+ pide(
355
+ puerto, "/api/ficha", metodo="POST",
356
+ cuerpo={"domain": "healthcare", "role": "clinical-director"},
357
+ )
358
+ c = card.lee(os.path.join(hallDatos, f"{owner}.md"))
359
+ texto_ficha = c.get("texto") or ""
360
+ comprueba(
361
+ "· and reads back through the shared reader",
362
+ (c["agent"], c["role"]),
363
+ (address, "clinical-director"),
364
+ )
365
+ afirma(
366
+ "· saving the seat carries the roster over instead of emptying the city",
367
+ "agents: [api]" in texto_ficha and "role.api: seo" in texto_ficha,
368
+ texto_ficha.split("---")[1],
369
+ )
370
+ afirma(
371
+ "· and nothing writes the legacy repos: shape any more",
372
+ "repos:" not in texto_ficha,
373
+ texto_ficha.split("---")[1],
374
+ )
375
+ estado_salud = json.loads(pide(puerto, "/api/estado")[1])
376
+ afirma(
377
+ "· domain and role knowledge land transparently in the city",
378
+ estado_salud["domain"] == "healthcare"
379
+ and os.path.isfile(os.path.join(hallDatos, "domains", "healthcare.md"))
380
+ and os.path.isfile(os.path.join(hallDatos, "roles", "clinical-director.md"))
381
+ and os.path.isfile(os.path.join(hallDatos, "roles", "seo.md")),
382
+ )
383
+ afirma(
384
+ "· the live capability view exposes the repo agent role",
385
+ estado_salud["skills"]["api"]["role"] == "seo",
386
+ str(estado_salud["skills"]),
387
+ )
388
+ comprueba(
389
+ "· roads can route by public domain and seat role",
390
+ (estado_salud["invitation"]["domain"], estado_salud["invitation"]["role"]),
391
+ ("healthcare", "clinical-director"),
392
+ )
393
+ comprueba(
394
+ "· an empty command is manual, not an empty string to run", c["objetivo"]["command"], ""
395
+ )
396
+
397
+ # The seat endpoint no longer takes a roster in any shape: sending one
398
+ # is ignored rather than obeyed, because agents have their own door and
399
+ # a second way in is how the two doors drifted apart in the first place.
400
+ st, cuerpo = pide(
401
+ puerto,
402
+ "/api/ficha",
403
+ metodo="POST",
404
+ cuerpo={
405
+ "domain": "healthcare",
406
+ "role": "clinical-director",
407
+ "repos": ["inventado"],
408
+ "repo_roles": {"inventado": "seo"},
409
+ },
410
+ )
411
+ texto_tras = card.lee(os.path.join(hallDatos, f"{owner}.md")).get("texto") or ""
412
+ afirma(
413
+ "· a roster sent to the seat endpoint is ignored, not written",
414
+ st == 200 and "inventado" not in texto_tras and "agents: [api]" in texto_tras,
415
+ texto_tras.split("---")[1],
416
+ )
417
+
418
+ # A goal with no title is no goal, not a goal called nothing.
419
+ pide(
420
+ puerto,
421
+ "/api/ficha",
422
+ metodo="POST",
423
+ cuerpo={
424
+ "user": "bo@x.com",
425
+ "domain": "healthcare",
426
+ "role": "clinical-director",
427
+ "repos": [],
428
+ "objetivo": {"title": " "},
429
+ },
430
+ )
431
+ comprueba(
432
+ "· a blank goal title means no goal",
433
+ card.lee(os.path.join(hallDatos, f"{owner}.md"))["objetivo"],
434
+ None,
435
+ )
436
+
437
+ # Districts: the wrong shape is a 400 the page can show, the right one
438
+ # round-trips through the shared reader.
439
+ st, _ = pide(puerto, "/api/unidades", metodo="POST", cuerpo={"unidades": "banking"})
440
+ comprueba("· districts as a string is a 400, not a crash", st, 400)
441
+ st, _ = pide(
442
+ puerto, "/api/unidades", metodo="POST", cuerpo={"unidades": [{"name": "no id"}]}
443
+ )
444
+ comprueba("· a district with no id, likewise", st, 400)
445
+ st, cuerpo = pide(
446
+ puerto,
447
+ "/api/unidades",
448
+ metodo="POST",
449
+ cuerpo={"unidades": [{"id": "Banca Digital", "name": "Banca", "color": "#3FB8A0"}]},
450
+ )
451
+ r = json.loads(cuerpo)
452
+ comprueba("· a real district is written and cleaned", st, 200)
453
+ comprueba(
454
+ "· id slugged, colour lowered",
455
+ (r["unidades"][0]["id"], r["unidades"][0]["color"]),
456
+ ("banca-digital", "3fb8a0"),
457
+ )
458
+
459
+ # Houses: same deal.
460
+ st, _ = pide(puerto, "/api/parcelas", metodo="POST", cuerpo={"repos": ["x"]})
461
+ comprueba("· houses as a list is a 400", st, 400)
462
+ st, cuerpo = pide(
463
+ puerto,
464
+ "/api/parcelas",
465
+ metodo="POST",
466
+ cuerpo={
467
+ "repos": {
468
+ "api": [
469
+ {"ruta": "src/**", "unidad": "banca-digital", "nombre": "api · src"},
470
+ {"ruta": "", "unidad": "none", "nombre": "api · rest"},
471
+ ]
472
+ },
473
+ "lab": ["lab-x"],
474
+ },
475
+ )
476
+ r = json.loads(cuerpo)
477
+ comprueba(
478
+ "· a split repo round-trips through the shared reader",
479
+ [(p["id"], p["unidad"]) for p in r["parcelas"]],
480
+ [("api:src/**", "banca-digital"), ("api", "none")],
481
+ )
482
+ # And estado now reflects all of it — the page repaints from this.
483
+ estado = json.loads(pide(puerto, "/api/estado")[1])
484
+ comprueba(
485
+ "· estado sees one seat, two houses, one district",
486
+ (
487
+ len(estado["tarjetas"]),
488
+ len(estado["parcelas"]),
489
+ len(estado["unidades"]),
490
+ estado["lab"],
491
+ ),
492
+ (1, 2, 1, ["lab-x"]),
493
+ )
494
+
495
+ # ── several cities and their roads through one hall ────────────────
496
+ st, cuerpo = pide(puerto, "/api/ciudades", metodo="POST", cuerpo={"name": "Otra"})
497
+ creada = json.loads(cuerpo)
498
+ afirma(
499
+ "· the hall creates another city below the same user",
500
+ st == 200 and creada.get("address") == "halltest/otra",
501
+ str(creada),
502
+ )
503
+ otra = creada["city"]
504
+ est1 = json.loads(pide(puerto, "/api/estado")[1])
505
+ est2 = json.loads(pide(puerto, "/api/estado?city=" + otra)[1])
506
+ comprueba(
507
+ "· ?city= switches which city the hall manages",
508
+ (est1["datos"] != est2["datos"], os.path.realpath(otra) == est2["datos"]),
509
+ (True, True),
510
+ )
511
+ afirma(
512
+ "· the session name follows the city, so attach commands are right",
513
+ est2["sesion"].endswith("-otra") and not est1["sesion"].endswith("-otra"),
514
+ f"{est1['sesion']} / {est2['sesion']}",
515
+ )
516
+ afirma(
517
+ "· and the switcher gets the list with the current one marked",
518
+ any(c["actual"] for c in est2["ciudades"]),
519
+ )
520
+ st, cuerpo = pide(
521
+ puerto,
522
+ "/api/roads",
523
+ metodo="POST",
524
+ cuerpo={"action": "connect", "target": est2["city_id"]},
525
+ )
526
+ r = json.loads(cuerpo)
527
+ afirma(
528
+ "· opening a local road writes the current endpoint",
529
+ st == 200 and r["roads"][0]["address"] == "halltest/otra",
530
+ str(r),
531
+ )
532
+ otra_estado = json.loads(pide(puerto, "/api/estado?city=" + otra)[1])
533
+ afirma(
534
+ "· and the other endpoint is symmetric",
535
+ otra_estado["roads"][0]["id"] == est1["city_id"],
536
+ str(otra_estado["roads"]),
537
+ )
538
+ st, cuerpo = pide(
539
+ puerto,
540
+ "/api/roads",
541
+ metodo="POST",
542
+ cuerpo={"action": "disconnect", "target": est2["city_id"]},
543
+ )
544
+ afirma(
545
+ "· closing it removes both endpoints",
546
+ st == 200
547
+ and json.loads(cuerpo)["roads"] == []
548
+ and json.loads(pide(puerto, "/api/estado?city=" + otra)[1])["roads"] == [],
549
+ )
550
+ # A write follows the same request's city — never the default's.
551
+ pide(
552
+ puerto,
553
+ "/api/unidades?city=" + otra,
554
+ metodo="POST",
555
+ cuerpo={"unidades": [{"id": "u1", "name": "U1", "color": "aabbcc"}]},
556
+ )
557
+ afirma(
558
+ "· a save lands in the requested city, not the default",
559
+ os.path.exists(os.path.join(otra, "units.yml"))
560
+ and "u1" in open(os.path.join(otra, "units.yml")).read(),
561
+ )
562
+ afirma(
563
+ "· and the default city was not touched by it",
564
+ not os.path.exists(os.path.join(hallDatos, "units.yml"))
565
+ or "u1" not in open(os.path.join(hallDatos, "units.yml")).read(),
566
+ )
567
+ shutil.rmtree(otra)
568
+
569
+ # ── the demo's remote control ────────────────────────────────────────
570
+ print(" the demo's remote control")
571
+ # Which story a demo replays comes from its city.yml domain AS WRITTEN.
572
+ # The registry normalises unknown domains to software, which is exactly
573
+ # how the clinic once replayed Aurora's night instead of its own
574
+ # morning — this is that bug's tombstone.
575
+ comprueba(
576
+ "· each packaged demo city replays its own domain's story",
577
+ [
578
+ serve.historia_del_demo(os.path.join(RAIZ, "demo", d))
579
+ for d in ("city", "clinica", "despacho")
580
+ ],
581
+ ["software", "medicina", "legal"],
582
+ )
583
+ comprueba(
584
+ "· a demo with an unknown or missing domain falls back to software",
585
+ serve.historia_del_demo(tempfile.mkdtemp()),
586
+ "software",
587
+ )
588
+ # The control verbs, against a city dressed as a demo. `restart` is NOT
589
+ # exercised live on purpose: it spawns the real storyteller against the
590
+ # real bus; its story choice and its 403 on real cities are covered.
591
+ with open(os.path.join(hallDatos, "city.yml"), "w", encoding="utf-8") as f:
592
+ f.write("id: city_demo_test_v1\nname: Test Demo\nslug: test-demo\nowner: halltest\n")
593
+ st, _ = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "volar"})
594
+ comprueba("· an unknown control verb is a 400, not a surprise", st, 400)
595
+ st, cuerpo_demo = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "status"})
596
+ estado_demo = json.loads(cuerpo_demo)
597
+ afirma(
598
+ "· status answers honestly when nothing is playing",
599
+ st == 200 and estado_demo.get("ok") and not estado_demo.get("running"),
600
+ str(estado_demo),
601
+ )
602
+ st, cuerpo_demo = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "pause"})
603
+ estado_demo = json.loads(cuerpo_demo)
604
+ afirma(
605
+ "· pausing silence is a safe no-op, never a crash",
606
+ st == 200 and not estado_demo.get("running") and not estado_demo.get("paused"),
607
+ str(estado_demo),
608
+ )
609
+ os.unlink(os.path.join(hallDatos, "city.yml"))
610
+
611
+ # ── the agents' character sheets ─────────────────────────────────────
612
+ print(" the agents' character sheets")
613
+ # An agents-first card: a knowledge agent with its workspace on disk,
614
+ # so discovery, growth and the engine keys all have something real.
615
+ taller = os.path.join(hallDatos, "agents", "notas")
616
+ os.makedirs(taller, exist_ok=True)
617
+ open(os.path.join(taller, "apuntes.md"), "w").write("# notas\n")
618
+ with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
619
+ f.write(
620
+ "---\n"
621
+ "user: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
622
+ "agents: [notas]\nkind.notas: knowledge\nrole.notas: apuntes\n"
623
+ "runs.notas: claude\nmodel.notas: opus\neffort.notas: high\n"
624
+ "goals_defined: true\n---\n"
625
+ )
626
+ st, cuerpo = pide(puerto, "/api/estado")
627
+ estado = json.loads(cuerpo)
628
+ hoja = next((x for x in estado.get("agents", []) if x["name"] == "notas"), None)
629
+ afirma(
630
+ "· the sheet carries kind, engine and growth — all read, none invented",
631
+ st == 200
632
+ and hoja is not None
633
+ and hoja["kind"] == "knowledge"
634
+ and hoja["runtime"] == "claude"
635
+ and hoja["model"] == "opus"
636
+ and hoja["effort"] == "high"
637
+ and hoja["growth"]["floors"] >= 1,
638
+ str(hoja),
639
+ )
640
+ afirma(
641
+ "· an agents-first agent is discovered with a face and a skills entry",
642
+ "notas" in estado.get("avatars", {}) and "notas" in estado.get("skills", {}),
643
+ str(sorted(estado.get("skills", {}).keys())),
644
+ )
645
+ st, cuerpo = pide(
646
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": "haiku"}
647
+ )
648
+ respuesta = json.loads(cuerpo)
649
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
650
+ afirma(
651
+ "· happy: tuning the engine persists to the card key the launcher reads",
652
+ st == 200 and respuesta["agent"]["model"] == "haiku" and "model.notas: haiku" in carta,
653
+ carta,
654
+ )
655
+ st, cuerpo = pide(
656
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": ""}
657
+ )
658
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
659
+ afirma(
660
+ "· happy: an empty value removes the key — back to the owner's default",
661
+ st == 200 and "model.notas" not in carta,
662
+ carta,
663
+ )
664
+ st, _ = pide(
665
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "nadie", "model": "opus"}
666
+ )
667
+ comprueba("· non-happy: an unknown agent is a 404, never a write", st, 404)
668
+ st, _ = pide(
669
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "effort": "turbo"}
670
+ )
671
+ comprueba("· non-happy: an invented effort level is refused", st, 400)
672
+ st, _ = pide(
673
+ puerto,
674
+ "/api/agente",
675
+ metodo="POST",
676
+ cuerpo={"agent": "notas", "model": "opus; rm -rf /"},
677
+ )
678
+ comprueba("· non-happy: a model value is an alias, never a shell fragment", st, 400)
679
+ st, cuerpo = pide(
680
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "runtime": "codex"}
681
+ )
682
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
683
+ afirma(
684
+ "· happy: the provider is a card key too",
685
+ st == 200 and "runs.notas: codex" in carta,
686
+ carta,
687
+ )
688
+ st, _ = pide(
689
+ puerto,
690
+ "/api/agente",
691
+ metodo="POST",
692
+ cuerpo={"agent": "notas", "runtime": "terminal:sh"},
693
+ )
694
+ comprueba("· non-happy: the web never plants a terminal command", st, 400)
695
+ st, cuerpo = pide(
696
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": "abc123"}
697
+ )
698
+ cara_nueva = json.loads(cuerpo)["agent"]["avatar"]
699
+ st2, cuerpo2 = pide(
700
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": ""}
701
+ )
702
+ afirma(
703
+ "· happy: a face reroll is a seed on the card, and clearing it restores the classic",
704
+ st == 200
705
+ and st2 == 200
706
+ and cara_nueva != json.loads(cuerpo2)["agent"]["avatar"],
707
+ "",
708
+ )
709
+ st, _ = pide(
710
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": "MAL$SEED"}
711
+ )
712
+ comprueba("· non-happy: an avatar seed is short and plain", st, 400)
713
+ # A 400 must never be an answer that already mutated the card: the
714
+ # handler once wrote field-by-field, so a valid model landed before an
715
+ # invalid effort was refused.
716
+ pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": "sonnet"})
717
+ st, _ = pide(
718
+ puerto,
719
+ "/api/agente",
720
+ metodo="POST",
721
+ cuerpo={"agent": "notas", "model": "haiku", "effort": "turbo"},
722
+ )
723
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
724
+ afirma(
725
+ "· non-happy: a refused edit leaves the whole card untouched",
726
+ st == 400 and "model.notas: sonnet" in carta and "haiku" not in carta,
727
+ carta,
728
+ )
729
+ pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": ""})
730
+
731
+ # ── the roster, from the web ─────────────────────────────────────────
732
+ print(" building the roster from the Hall")
733
+ st, cuerpo = pide(
734
+ puerto,
735
+ "/api/agentes",
736
+ metodo="POST",
737
+ cuerpo={"name": "urgencias web", "kind": "knowledge", "role": "triage"},
738
+ )
739
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
740
+ afirma(
741
+ "· happy: an agent can be added without ever opening a terminal",
742
+ st == 200
743
+ and "urgencias web" in carta
744
+ and "kind.urgencias-web: knowledge" in carta
745
+ and "role.urgencias-web: triage" in carta,
746
+ carta,
747
+ )
748
+ st, _ = pide(
749
+ puerto, "/api/agentes", metodo="POST",
750
+ cuerpo={"name": "urgencias web", "kind": "code", "role": "dev"},
751
+ )
752
+ comprueba("· non-happy: two agents cannot share one identity", st, 409)
753
+ st, _ = pide(
754
+ puerto, "/api/agentes", metodo="POST",
755
+ cuerpo={"name": "nova", "kind": "wizard", "role": "dev"},
756
+ )
757
+ comprueba("· non-happy: a kind is code, knowledge or coordinator", st, 400)
758
+ st, _ = pide(
759
+ puerto, "/api/agentes", metodo="POST",
760
+ cuerpo={"name": "nova", "kind": "code", "role": "dev; rm -rf /"},
761
+ )
762
+ comprueba("· non-happy: a role is an id, never a shell fragment", st, 400)
763
+ st, _ = pide(puerto, "/api/agentes", metodo="POST", cuerpo={"name": " "})
764
+ comprueba("· non-happy: an agent needs a name", st, 400)
765
+
766
+ # Mounts: what an agent works on, from the same page.
767
+ carpeta = tempfile.mkdtemp()
768
+ st, cuerpo = pide(
769
+ puerto, "/api/montaje", metodo="POST",
770
+ cuerpo={"agent": "urgencias-web", "add": carpeta},
771
+ )
772
+ montado = json.loads(cuerpo)
773
+ # The label is slugged: it is a card key and a filename, not free text.
774
+ etiqueta = card.ventana(os.path.basename(carpeta))
775
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
776
+ afirma(
777
+ "· happy: a document folder is mounted, on disk and on the card together",
778
+ st == 200
779
+ and any(m["label"] == etiqueta for m in montado.get("mounts", []))
780
+ and f"mounts.urgencias-web: [{os.path.realpath(carpeta)}]" in carta,
781
+ f"{cuerpo!r} {carta}",
782
+ )
783
+ st, cuerpo = pide(puerto, "/api/estado")
784
+ hoja_web = next(
785
+ x for x in json.loads(cuerpo)["agents"] if x["slug"] == "urgencias-web"
786
+ )
787
+ afirma(
788
+ "· and the sheet says what it works on, not merely how many",
789
+ [m["label"] for m in hoja_web["mounts"]] == [etiqueta],
790
+ str(hoja_web["mounts"]),
791
+ )
792
+ st, _ = pide(
793
+ puerto, "/api/montaje", metodo="POST",
794
+ cuerpo={"agent": "urgencias-web", "add": "/nope/not/here"},
795
+ )
796
+ comprueba("· non-happy: a folder that is not there is refused", st, 400)
797
+ st, cuerpo = pide(
798
+ puerto, "/api/montaje", metodo="POST",
799
+ cuerpo={"agent": "urgencias-web", "remove": etiqueta},
800
+ )
801
+ carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
802
+ afirma(
803
+ "· happy: unmounting clears the link and the card key, and keeps the folder",
804
+ st == 200
805
+ and not json.loads(cuerpo)["mounts"]
806
+ and "mounts.urgencias-web" not in carta
807
+ and os.path.isdir(carpeta),
808
+ carta,
809
+ )
810
+ st, _ = pide(
811
+ puerto, "/api/montaje", metodo="POST",
812
+ cuerpo={"agent": "urgencias-web", "remove": "fantasma"},
813
+ )
814
+ comprueba("· non-happy: unmounting what is not mounted is a 404", st, 404)
815
+ shutil.rmtree(carpeta, ignore_errors=True)
816
+
817
+ # ── instructions and skills, into the agent's own home ──────────────
818
+ print(" instructions and skills, written only into the agent's own home")
819
+ st, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
820
+ antes = json.loads(cuerpo)
821
+ afirma(
822
+ "· happy: a missing instruction file reads honestly as absent, with its reader",
823
+ st == 200 and antes["exists"] is False and antes["reader"] == "claude",
824
+ str(antes),
825
+ )
826
+ st, _ = pide(
827
+ puerto,
828
+ "/api/instrucciones",
829
+ metodo="POST",
830
+ cuerpo={"agent": "notas", "file": "CLAUDE.md", "content": "# Notas\nSé breve.\n"},
831
+ )
832
+ st2, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
833
+ despues = json.loads(cuerpo)
834
+ afirma(
835
+ "· happy: written atomically into the workspace and read back verbatim",
836
+ st == 200 and st2 == 200 and despues["exists"] and "Sé breve." in despues["content"],
837
+ str(despues),
838
+ )
839
+ st, _ = pide(puerto, "/api/instrucciones?agent=notas&file=HACK.md")
840
+ comprueba("· non-happy: only CLAUDE.md and AGENTS.md exist here", st, 400)
841
+ # A latin-1 CLAUDE.md once escaped the handler as a UnicodeDecodeError
842
+ # and dropped the connection; and `exists: false` would be worse — the
843
+ # editor would save over what it could not read.
844
+ with open(os.path.join(taller, "CLAUDE.md"), "wb") as f:
845
+ f.write("Triaje: café\n".encode("latin-1"))
846
+ st, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
847
+ afirma(
848
+ "· non-happy: a non-UTF-8 file is a JSON refusal, never a dropped connection",
849
+ st == 409 and "not UTF-8" in json.loads(cuerpo).get("error", ""),
850
+ cuerpo.decode(errors="replace"),
851
+ )
852
+ pide(
853
+ puerto,
854
+ "/api/instrucciones",
855
+ metodo="POST",
856
+ cuerpo={"agent": "notas", "file": "CLAUDE.md", "content": "# Notas\nSé breve.\n"},
857
+ )
858
+ st, _ = pide(
859
+ puerto,
860
+ "/api/instrucciones",
861
+ metodo="POST",
862
+ cuerpo={"agent": "nadie", "file": "CLAUDE.md", "content": "x"},
863
+ )
864
+ comprueba("· non-happy: an unknown agent gets no file", st, 404)
865
+
866
+ import base64 as _b64
867
+ import io as _io
868
+ import zipfile as _zip
869
+
870
+ def zipea(entradas, enlace=None):
871
+ crudo = _io.BytesIO()
872
+ with _zip.ZipFile(crudo, "w") as z:
873
+ for nombre, contenido in entradas:
874
+ z.writestr(nombre, contenido)
875
+ if enlace:
876
+ info = _zip.ZipInfo(enlace)
877
+ info.external_attr = (0o120777 << 16)
878
+ z.writestr(info, "/etc/passwd")
879
+ return _b64.b64encode(crudo.getvalue()).decode()
880
+
881
+ bueno = zipea([("mi-skill/SKILL.md", "---\nname: mi-skill\n---\nhola"),
882
+ ("mi-skill/extra.md", "más")])
883
+ st, cuerpo = pide(
884
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "zip": bueno}
885
+ )
886
+ st2, cuerpo2 = pide(puerto, "/api/estado")
887
+ skills_notas = json.loads(cuerpo2)["skills"].get("notas", {}).get("skills", [])
888
+ afirma(
889
+ "· happy: an uploaded skill lands in the agent's home and is discovered live",
890
+ st == 200 and any(s["name"] == "mi-skill" for s in skills_notas),
891
+ str(skills_notas),
892
+ )
893
+ st, _ = pide(
894
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "zip": bueno}
895
+ )
896
+ comprueba("· non-happy: installing over an existing skill is refused", st, 409)
897
+ st, _ = pide(
898
+ puerto,
899
+ "/api/skill",
900
+ metodo="POST",
901
+ cuerpo={"agent": "notas", "zip": zipea([("../fuera/SKILL.md", "x")])},
902
+ )
903
+ comprueba("· non-happy: a zip that tries to escape its folder is refused", st, 400)
904
+ st, _ = pide(
905
+ puerto,
906
+ "/api/skill",
907
+ metodo="POST",
908
+ cuerpo={
909
+ "agent": "notas",
910
+ "zip": zipea([("liada/SKILL.md", "x")], enlace="liada/mala"),
911
+ },
912
+ )
913
+ comprueba("· non-happy: symlinks inside a skill are refused", st, 400)
914
+ st, _ = pide(
915
+ puerto,
916
+ "/api/skill",
917
+ metodo="POST",
918
+ cuerpo={"agent": "notas", "name": "sin-manifiesto", "zip": zipea([("leeme.md", "x")])},
919
+ )
920
+ comprueba("· non-happy: a skill is a folder with a SKILL.md, or nothing", st, 400)
921
+ st, _ = pide(
922
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "mi-skill"}
923
+ )
924
+ st2, cuerpo2 = pide(puerto, "/api/estado")
925
+ skills_notas = json.loads(cuerpo2)["skills"].get("notas", {}).get("skills", [])
926
+ afirma(
927
+ "· happy: removing an installed skill empties exactly its folder",
928
+ st == 200 and not any(s["name"] == "mi-skill" for s in skills_notas),
929
+ str(skills_notas),
930
+ )
931
+ st, _ = pide(
932
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "fantasma"}
933
+ )
934
+ comprueba("· non-happy: removing a skill that is not there is a 404", st, 404)
935
+
936
+ # The 14 MB cap bounds the upload; only the extraction budget bounds
937
+ # what a hostile deflate ratio inflates it to on disk. 80 MB of zeros
938
+ # deflates to under 100 KB — small on the wire, huge when it lands.
939
+ crudo_bomba = _io.BytesIO()
940
+ with _zip.ZipFile(crudo_bomba, "w", _zip.ZIP_DEFLATED) as z:
941
+ z.writestr("boom/SKILL.md", "---\nname: boom\n---\n")
942
+ z.writestr("boom/ceros.bin", b"\0" * (80 * 1024 * 1024))
943
+ st, cuerpo = pide(
944
+ puerto,
945
+ "/api/skill",
946
+ metodo="POST",
947
+ cuerpo={
948
+ "agent": "notas",
949
+ "zip": _b64.b64encode(crudo_bomba.getvalue()).decode(),
950
+ },
951
+ )
952
+ afirma(
953
+ "· non-happy: a zip bomb is refused by its decompressed size, not its upload size",
954
+ st == 400 and "inflates too large" in json.loads(cuerpo).get("error", ""),
955
+ cuerpo.decode(errors="replace"),
956
+ )
957
+ # A zip whose entries collide (a file, then a directory of the same
958
+ # name) once killed the handler mid-write and its debris 409'd every
959
+ # retry until deleted by hand.
960
+ st, _ = pide(
961
+ puerto,
962
+ "/api/skill",
963
+ metodo="POST",
964
+ cuerpo={
965
+ "agent": "notas",
966
+ "zip": zipea([("s/SKILL.md", "---\nname: s\n---\n"), ("s/f", "x"), ("s/f/g", "y")]),
967
+ },
968
+ )
969
+ st2, _ = pide(
970
+ puerto,
971
+ "/api/skill",
972
+ metodo="POST",
973
+ cuerpo={"agent": "notas", "zip": zipea([("s/SKILL.md", "---\nname: s\n---\n")])},
974
+ )
975
+ afirma(
976
+ "· non-happy: a colliding zip is a 400 that cleans up — the retry installs fine",
977
+ st == 400 and st2 == 200,
978
+ f"{st}/{st2}",
979
+ )
980
+ pide(puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "s"})
981
+ # Removal is keyed on the FOLDER, and the sheet is told which skills the
982
+ # Hall itself could remove: a SKILL.md display name is free to differ,
983
+ # and a repo's committed skill is the repo's property — no × for it.
984
+ pide(
985
+ puerto,
986
+ "/api/skill",
987
+ metodo="POST",
988
+ cuerpo={
989
+ "agent": "notas",
990
+ "zip": zipea([("deploy-tools/SKILL.md", "---\nname: deploy\n---\n")]),
991
+ },
992
+ )
993
+ with open(os.path.join(taller, "SKILL.md"), "w", encoding="utf-8") as f:
994
+ f.write("---\nname: del-repo\n---\n")
995
+ st, cuerpo = pide(puerto, "/api/estado")
996
+ skills_notas = json.loads(cuerpo)["skills"].get("notas", {}).get("skills", [])
997
+ subida = next((s for s in skills_notas if s["name"] == "deploy"), None)
998
+ del_repo = next((s for s in skills_notas if s["name"] == "del-repo"), None)
999
+ afirma(
1000
+ "· happy: an installed skill is marked removable, keyed on its folder",
1001
+ subida is not None
1002
+ and subida.get("removable") is True
1003
+ and subida.get("dir") == "deploy-tools",
1004
+ str(subida),
1005
+ )
1006
+ afirma(
1007
+ "· a skill committed elsewhere in the home is discovered but never removable",
1008
+ del_repo is not None and not del_repo.get("removable"),
1009
+ str(del_repo),
1010
+ )
1011
+ st, _ = pide(
1012
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "deploy"}
1013
+ )
1014
+ comprueba("· non-happy: the display name is not the folder — a 404, never a guess", st, 404)
1015
+ st, _ = pide(
1016
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "deploy-tools"}
1017
+ )
1018
+ comprueba("· happy: removal by folder name removes exactly that folder", st, 200)
1019
+ os.unlink(os.path.join(taller, "SKILL.md"))
1020
+ # A repo can commit `.claude/skills` as a symlink to anywhere this user
1021
+ # can write — the global skills folder included. Both the install and
1022
+ # the rmtree once followed it out of the agent's home.
1023
+ raiz_skills = os.path.join(taller, ".claude", "skills")
1024
+ shutil.rmtree(raiz_skills, ignore_errors=True)
1025
+ fuera_de_casa = tempfile.mkdtemp()
1026
+ os.symlink(fuera_de_casa, raiz_skills)
1027
+ st, _ = pide(
1028
+ puerto,
1029
+ "/api/skill",
1030
+ metodo="POST",
1031
+ cuerpo={"agent": "notas", "zip": zipea([("fuga/SKILL.md", "---\nname: fuga\n---\n")])},
1032
+ )
1033
+ st2, _ = pide(
1034
+ puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "fuga"}
1035
+ )
1036
+ afirma(
1037
+ "· non-happy: a symlinked .claude/skills is refused for install and removal alike",
1038
+ st == 409 and st2 == 409 and not os.listdir(fuera_de_casa),
1039
+ f"{st}/{st2}/{os.listdir(fuera_de_casa)}",
1040
+ )
1041
+ os.unlink(raiz_skills)
1042
+ shutil.rmtree(fuera_de_casa, ignore_errors=True)
1043
+
1044
+ # ── the engine's traffic light and its test button ───────────────────
1045
+ print(" the engine's traffic light, derived and never guessed")
1046
+ # A fake binary makes the light deterministic on any machine, CI included.
1047
+ cajon = tempfile.mkdtemp()
1048
+ falso = os.path.join(cajon, "kimi")
1049
+ with open(falso, "w") as f:
1050
+ f.write('#!/bin/sh\necho "kimi 9.9.9"\n')
1051
+ os.chmod(falso, 0o755)
1052
+ ruta_previa = os.environ["PATH"]
1053
+ os.environ["PATH"] = cajon + os.pathsep + ruta_previa
1054
+ try:
1055
+ pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "runtime": "kimi"})
1056
+ st, cuerpo = pide(puerto, "/api/estado")
1057
+ hoja = next(x for x in json.loads(cuerpo)["agents"] if x["name"] == "notas")
1058
+ afirma(
1059
+ "· yellow: the binary exists and the agent's window is not running",
1060
+ st == 200
1061
+ and hoja["cli"]["binary"] == "kimi"
1062
+ and hoja["cli"]["installed"] is True
1063
+ and hoja["cli"]["connected"] is False,
1064
+ str(hoja["cli"]),
1065
+ )
1066
+ st, cuerpo = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "notas"})
1067
+ prueba = json.loads(cuerpo)
1068
+ afirma(
1069
+ "· happy: the test button runs the real binary and reports its version",
1070
+ st == 200 and prueba["ok"] is True and "9.9.9" in prueba.get("version", ""),
1071
+ str(prueba),
1072
+ )
1073
+ os.environ["PATH"] = cajon # only the fake folder: kimi gone nowhere, others missing
1074
+ os.remove(falso)
1075
+ st, cuerpo = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "notas"})
1076
+ prueba = json.loads(cuerpo)
1077
+ afirma(
1078
+ "· red: a missing binary is an honest answer, not an HTTP error",
1079
+ st == 200 and prueba["ok"] is False and "not on this machine" in prueba["detail"],
1080
+ str(prueba),
1081
+ )
1082
+ st, _ = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "nadie"})
1083
+ comprueba("· non-happy: testing an unknown agent is a 404", st, 404)
1084
+ finally:
1085
+ os.environ["PATH"] = ruta_previa
1086
+ shutil.rmtree(cajon, ignore_errors=True)
1087
+
1088
+ # ── regressions pinned by review ─────────────────────────────────────
1089
+ print(" review regressions, pinned")
1090
+ # tmux prefix-matches session names: bare `-t home` also matches
1091
+ # `home-2`, lighting the green dot from a different city's windows.
1092
+ fuente = open(os.path.join(AQUI, "serve.py"), encoding="utf-8").read()
1093
+ afirma(
1094
+ "· ventanas_vivas asks tmux for an exact session match",
1095
+ '"=" + cities.sesion(owner, datos)' in fuente,
1096
+ )
1097
+ # An 80-char slug is a legal window name; a 64-char cap on the resolver
1098
+ # made every sheet action 404 while the sheet itself rendered fine.
1099
+ largo = "l" * 70
1100
+ with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
1101
+ f.write(
1102
+ "---\n"
1103
+ "user: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
1104
+ f"agents: [notas, {largo}]\nkind.notas: knowledge\n"
1105
+ "goals_defined: true\n---\n"
1106
+ )
1107
+ st, cuerpo = pide(
1108
+ puerto, "/api/agente", metodo="POST", cuerpo={"agent": largo, "model": "haiku"}
1109
+ )
1110
+ afirma(
1111
+ "· a 65-80 char slug can use its sheet's actions, not only render it",
1112
+ st == 200 and json.loads(cuerpo)["agent"]["slug"] == largo,
1113
+ cuerpo.decode(errors="replace")[:200],
1114
+ )
1115
+ # Two names that slug to one identity: the card tools raise loudly;
1116
+ # the Hall and read-only discovery must degrade, never traceback.
1117
+ import capabilities as _cap
1118
+
1119
+ with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
1120
+ f.write(
1121
+ "---\nuser: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
1122
+ "agents: [store-service, store_service]\ngoals_defined: true\n---\n"
1123
+ )
1124
+ comprueba(
1125
+ "· skills discovery over a slug-colliding card lists nothing, crashes nothing",
1126
+ _cap.descubre_ciudad(hallDatos),
1127
+ {},
1128
+ )
1129
+ st, cuerpo = pide(puerto, "/api/estado")
1130
+ afirma(
1131
+ "· /api/estado still answers over a slug-colliding card",
1132
+ st == 200 and json.loads(cuerpo).get("agents") == [],
1133
+ cuerpo.decode(errors="replace")[:200],
1134
+ )
1135
+ # The Hall binds loopback without the stdlib's socket.getfqdn() call:
1136
+ # a reverse DNS lookup for a cosmetic name, which on an unanswered
1137
+ # resolver stalls the server's start for tens of seconds.
1138
+ import socket as _socket
1139
+ import threading as _th
1140
+ import time as _time
1141
+
1142
+ _fqdn = _socket.getfqdn
1143
+ _socket.getfqdn = lambda *a: _time.sleep(60) or "nunca"
1144
+ otro = {}
1145
+ try:
1146
+ atado = _th.Event()
1147
+
1148
+ def _ata():
1149
+ otro["s"] = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
1150
+ atado.set()
1151
+
1152
+ _th.Thread(target=_ata, daemon=True).start()
1153
+ afirma("· the Hall binds without waiting on reverse DNS", atado.wait(timeout=5))
1154
+ finally:
1155
+ _socket.getfqdn = _fqdn
1156
+ if otro.get("s"):
1157
+ otro["s"].server_close()
1158
+
1159
+ # Two memos serve /api/estado, and a memo without an invalidation is a
1160
+ # lie waiting to be told: mounting a folder changes exactly what growth
1161
+ # counts, and installing a skill changes discovery without touching the
1162
+ # card that keys it. A healthy card first — the checks above deliberately
1163
+ # left a broken one behind.
1164
+ with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
1165
+ f.write(
1166
+ "---\nuser: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
1167
+ "agents: [notas]\nkind.notas: knowledge\nrole.notas: apuntes\n"
1168
+ "goals_defined: true\n---\n"
1169
+ )
1170
+ serve._CRECIDO[(os.path.realpath(hallDatos), "notas")] = (
1171
+ __import__("time").monotonic(),
1172
+ {"floors": 999, "bricks": 0, "activity30": 0, "signal": "stale"},
1173
+ )
1174
+ pide(
1175
+ puerto, "/api/montaje", metodo="POST",
1176
+ cuerpo={"agent": "notas", "add": tempfile.mkdtemp()},
1177
+ )
1178
+ afirma(
1179
+ "· mounting a folder forgets that agent's remembered growth",
1180
+ (os.path.realpath(hallDatos), "notas") not in serve._CRECIDO,
1181
+ str(sorted(serve._CRECIDO)),
1182
+ )
1183
+ st, cuerpo = pide(puerto, "/api/estado")
1184
+ primera = json.loads(cuerpo)["skills"]
1185
+ sello_antes = serve._SKILLS.get("sello")
1186
+ pide(puerto, "/api/estado")
1187
+ afirma(
1188
+ "· discovery is remembered while the card has not moved",
1189
+ sello_antes is not None and serve._SKILLS.get("sello") == sello_antes,
1190
+ str(sello_antes),
1191
+ )
1192
+ pide(
1193
+ puerto, "/api/agentes", metodo="POST",
1194
+ cuerpo={"name": "efimero", "kind": "code", "role": "dev"},
1195
+ )
1196
+ st, cuerpo = pide(puerto, "/api/estado")
1197
+ afirma(
1198
+ "· and a card that moves invalidates it at once, not on a timer",
1199
+ "efimero" in json.loads(cuerpo)["skills"] and "efimero" not in primera,
1200
+ str(sorted(json.loads(cuerpo)["skills"])),
1201
+ )
1202
+
1203
+ # A code agent's growth reads real local git history — merge commits
1204
+ # as floors — instead of answering zero for lack of a counter.
1205
+ import subprocess as _sp
1206
+ import types as _types
1207
+
1208
+ repo = tempfile.mkdtemp()
1209
+ for orden in (
1210
+ ["git", "init", "-q"],
1211
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t",
1212
+ "commit", "--allow-empty", "-q", "-m", "uno"],
1213
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t",
1214
+ "commit", "--allow-empty", "-q", "-m", "dos"],
1215
+ ):
1216
+ _sp.run(orden, cwd=repo, capture_output=True)
1217
+ obrero = _types.SimpleNamespace(
1218
+ nombre="obrero", slug="obrero", legacy=False, workspace=repo, mounts=[]
1219
+ )
1220
+ prs, commits, act = serve.contador_git_local(obrero, hallDatos)
1221
+ afirma(
1222
+ "· the sheet's git counter reads real local history, no network",
1223
+ prs == 0 and commits == 2 and act == 2,
1224
+ f"{prs}/{commits}/{act}",
1225
+ )
1226
+ shutil.rmtree(repo, ignore_errors=True)
1227
+
1228
+ # /api/sesion is NOT exercised live on purpose: it spawns tmux windows
1229
+ # with real agents in them. Its refusal without the token is covered by
1230
+ # the blanket auth checks above, which is the part that must hold.
1231
+ finally:
1232
+ for k in (
1233
+ "AGENTS_CITY_DATA",
1234
+ "AGENTS_CITY_HOME",
1235
+ "AGENTS_CITY_USER",
1236
+ "CITY_SEARCH_IN",
1237
+ "XDG_CACHE_HOME",
1238
+ ):
1239
+ os.environ.pop(k, None)
1240
+ shutil.rmtree(hallDatos, ignore_errors=True)
1241
+ shutil.rmtree(appDatos, ignore_errors=True)
1242
+ shutil.rmtree(arbitraryDatos, ignore_errors=True)
1243
+ servidor.shutdown()
1244
+ servidor.server_close()
1245
+ shutil.rmtree(destino, ignore_errors=True)
1246
+
1247
+ return resumen("serve")
1248
+
1249
+
1250
+ if __name__ == "__main__":
1251
+ sys.exit(main())