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.
- package/.claude-plugin/marketplace.json +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2021 -0
- package/README.md +1999 -0
- package/benchmarks/committee/README.md +39 -0
- package/benchmarks/committee/metrics.py +51 -0
- package/benchmarks/committee/run.py +64 -0
- package/benchmarks/committee/traces/01-single.json +8 -0
- package/benchmarks/committee/traces/02-mesh.json +16 -0
- package/benchmarks/committee/traces/03-chair.json +15 -0
- package/benchmarks/latency/README.md +35 -0
- package/benchmarks/latency/fake-claude-cli.mjs +119 -0
- package/benchmarks/latency/fake-native-server.mjs +340 -0
- package/benchmarks/latency/fake-tui.py +91 -0
- package/benchmarks/latency/live.py +380 -0
- package/benchmarks/stress/README.md +20 -0
- package/benchmarks/stress/run.py +487 -0
- package/bin/agents +4 -0
- package/bin/agents-city.js +116 -0
- package/bin/benchmark +17 -0
- package/bin/bus +6 -0
- package/bin/cities +3 -0
- package/bin/city +86 -0
- package/bin/committee +6 -0
- package/bin/demo +200 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +426 -0
- package/bin/logs +3 -0
- package/bin/report.py +14 -0
- package/bin/reset +3 -0
- package/bin/road +3 -0
- package/bin/seat +10 -0
- package/bin/serve.py +1428 -0
- package/bin/setup.py +753 -0
- package/bin/skills +3 -0
- package/bin/test +131 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-avatar.py +58 -0
- package/bin/test-benchmark.py +40 -0
- package/bin/test-broker.py +334 -0
- package/bin/test-cage.py +206 -0
- package/bin/test-card.py +402 -0
- package/bin/test-channel.py +379 -0
- package/bin/test-cities.py +453 -0
- package/bin/test-claude-runtime.py +252 -0
- package/bin/test-committee.py +340 -0
- package/bin/test-contracts.py +544 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +106 -0
- package/bin/test-domains.py +151 -0
- package/bin/test-evidencia.py +50 -0
- package/bin/test-exit.py +128 -0
- package/bin/test-hall-protocol.py +93 -0
- package/bin/test-launch.py +169 -0
- package/bin/test-live-feed.py +508 -0
- package/bin/test-pairing.py +99 -0
- package/bin/test-parcels.py +283 -0
- package/bin/test-runtime-failures.py +252 -0
- package/bin/test-runtime-ui.py +488 -0
- package/bin/test-runtime.py +268 -0
- package/bin/test-rutas.py +80 -0
- package/bin/test-seat.py +1386 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1057 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +188 -0
- package/bin/testlib.py +95 -0
- package/bin/tokens.py +14 -0
- package/bus/scripts/deploy.sh +60 -0
- package/bus/scripts/mint-token.sh +68 -0
- package/bus/scripts/setup-dev.sh +79 -0
- package/bus/scripts/test-channel.ts +8 -0
- package/bus/scripts/test-hub.ts +266 -0
- package/bus/scripts/test-local.sh +40 -0
- package/bus/scripts/test-queue.ts +132 -0
- package/bus/worker/package-lock.json +1971 -0
- package/bus/worker/package.json +17 -0
- package/bus/worker/src/index.ts +485 -0
- package/bus/worker/tsconfig.json +13 -0
- package/bus/worker/wrangler.toml +32 -0
- package/city/oven/README.md +59 -0
- package/city/oven/collect.py +43 -0
- package/city/oven/oven.html +4 -0
- package/city/scripts/history.py +120 -0
- package/city/scripts/seed.py +442 -0
- package/city/web/assets/modelos/License.txt +28 -0
- package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/assets/modelos/building-a.glb +0 -0
- package/city/web/assets/modelos/building-b.glb +0 -0
- package/city/web/assets/modelos/building-c.glb +0 -0
- package/city/web/assets/modelos/building-d.glb +0 -0
- package/city/web/assets/modelos/building-e.glb +0 -0
- package/city/web/assets/modelos/building-f.glb +0 -0
- package/city/web/assets/modelos/building-g.glb +0 -0
- package/city/web/assets/modelos/building-h.glb +0 -0
- package/city/web/assets/sprites/building-a.png +0 -0
- package/city/web/assets/sprites/building-b.png +0 -0
- package/city/web/assets/sprites/building-c.png +0 -0
- package/city/web/assets/sprites/building-d.png +0 -0
- package/city/web/assets/sprites/building-e.png +0 -0
- package/city/web/assets/sprites/building-f.png +0 -0
- package/city/web/assets/sprites/building-g.png +0 -0
- package/city/web/assets/sprites/building-h.png +0 -0
- package/city/web/assets/sprites/building-i.png +0 -0
- package/city/web/assets/sprites/building-j.png +0 -0
- package/city/web/assets/sprites/building-k.png +0 -0
- package/city/web/assets/sprites/building-l.png +0 -0
- package/city/web/assets/sprites/building-m.png +0 -0
- package/city/web/assets/sprites/building-n.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
- package/city/web/assets/sprites/catalogo.json +47 -0
- package/city/web/assets/sprites/detail-awning-wide.png +0 -0
- package/city/web/assets/sprites/detail-awning.png +0 -0
- package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
- package/city/web/assets/sprites/detail-overhang.png +0 -0
- package/city/web/assets/sprites/detail-parasol-a.png +0 -0
- package/city/web/assets/sprites/detail-parasol-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-c.png +0 -0
- package/city/web/assets/sprites/low-detail-building-d.png +0 -0
- package/city/web/assets/sprites/low-detail-building-e.png +0 -0
- package/city/web/assets/sprites/low-detail-building-f.png +0 -0
- package/city/web/assets/sprites/low-detail-building-g.png +0 -0
- package/city/web/assets/sprites/low-detail-building-h.png +0 -0
- package/city/web/assets/sprites/low-detail-building-i.png +0 -0
- package/city/web/assets/sprites/low-detail-building-j.png +0 -0
- package/city/web/assets/sprites/low-detail-building-k.png +0 -0
- package/city/web/assets/sprites/low-detail-building-l.png +0 -0
- package/city/web/assets/sprites/low-detail-building-m.png +0 -0
- package/city/web/assets/sprites/low-detail-building-n.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/assets/sprites/medidas.json +248 -0
- package/city/web/dist/city.js +939 -0
- package/city/web/dist/index.html +294 -0
- package/city/web/dist/modelos/License.txt +28 -0
- package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/dist/modelos/building-a.glb +0 -0
- package/city/web/dist/modelos/building-b.glb +0 -0
- package/city/web/dist/modelos/building-c.glb +0 -0
- package/city/web/dist/modelos/building-d.glb +0 -0
- package/city/web/dist/modelos/building-e.glb +0 -0
- package/city/web/dist/modelos/building-f.glb +0 -0
- package/city/web/dist/modelos/building-g.glb +0 -0
- package/city/web/dist/modelos/building-h.glb +0 -0
- package/city/web/dist/sprites/building-a.png +0 -0
- package/city/web/dist/sprites/building-b.png +0 -0
- package/city/web/dist/sprites/building-c.png +0 -0
- package/city/web/dist/sprites/building-d.png +0 -0
- package/city/web/dist/sprites/building-e.png +0 -0
- package/city/web/dist/sprites/building-f.png +0 -0
- package/city/web/dist/sprites/building-g.png +0 -0
- package/city/web/dist/sprites/building-h.png +0 -0
- package/city/web/dist/sprites/building-i.png +0 -0
- package/city/web/dist/sprites/building-j.png +0 -0
- package/city/web/dist/sprites/building-k.png +0 -0
- package/city/web/dist/sprites/building-l.png +0 -0
- package/city/web/dist/sprites/building-m.png +0 -0
- package/city/web/dist/sprites/building-n.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
- package/city/web/dist/sprites/catalogo.json +47 -0
- package/city/web/dist/sprites/detail-awning-wide.png +0 -0
- package/city/web/dist/sprites/detail-awning.png +0 -0
- package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
- package/city/web/dist/sprites/detail-overhang.png +0 -0
- package/city/web/dist/sprites/detail-parasol-a.png +0 -0
- package/city/web/dist/sprites/detail-parasol-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-c.png +0 -0
- package/city/web/dist/sprites/low-detail-building-d.png +0 -0
- package/city/web/dist/sprites/low-detail-building-e.png +0 -0
- package/city/web/dist/sprites/low-detail-building-f.png +0 -0
- package/city/web/dist/sprites/low-detail-building-g.png +0 -0
- package/city/web/dist/sprites/low-detail-building-h.png +0 -0
- package/city/web/dist/sprites/low-detail-building-i.png +0 -0
- package/city/web/dist/sprites/low-detail-building-j.png +0 -0
- package/city/web/dist/sprites/low-detail-building-k.png +0 -0
- package/city/web/dist/sprites/low-detail-building-l.png +0 -0
- package/city/web/dist/sprites/low-detail-building-m.png +0 -0
- package/city/web/dist/sprites/low-detail-building-n.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/dist/sprites/medidas.json +248 -0
- package/city/web/dist-hall/hall.js +264 -0
- package/city/web/index.html +294 -0
- package/city/web/package.json +18 -0
- package/city/web/sella.py +21 -0
- package/city/web/src/activity-actors.ts +47 -0
- package/city/web/src/activity.ts +172 -0
- package/city/web/src/ayuntamiento.ts +580 -0
- package/city/web/src/draw.ts +640 -0
- package/city/web/src/game-speech.ts +79 -0
- package/city/web/src/hall.ts +1867 -0
- package/city/web/src/main.ts +2225 -0
- package/city/web/src/oven.ts +124 -0
- package/city/web/src/people.ts +244 -0
- package/city/web/src/presencia.ts +193 -0
- package/city/web/src/puertas.ts +180 -0
- package/city/web/src/repo-roles.ts +59 -0
- package/city/worker/package.json +16 -0
- package/city/worker/schema.sql +117 -0
- package/city/worker/src/index.ts +702 -0
- package/city/worker/src/square.ts +218 -0
- package/city/worker/wrangler.toml +58 -0
- package/demo/ada.md +50 -0
- package/demo/bruno.md +62 -0
- package/demo/camila.md +56 -0
- package/demo/city/ada.md +40 -0
- package/demo/city/city.yml +8 -0
- package/demo/city/parcels.yml +15 -0
- package/demo/city/roads.json +4 -0
- package/demo/city/units.yml +10 -0
- package/demo/clinica/city.yml +8 -0
- package/demo/clinica/parcels.yml +15 -0
- package/demo/clinica/roads.json +4 -0
- package/demo/clinica/units.yml +9 -0
- package/demo/clinica/vera.md +46 -0
- package/demo/dante.md +54 -0
- package/demo/despacho/city.yml +8 -0
- package/demo/despacho/marta.md +46 -0
- package/demo/despacho/parcels.yml +15 -0
- package/demo/despacho/roads.json +4 -0
- package/demo/despacho/units.yml +9 -0
- package/demo/elsa.md +50 -0
- package/demo/farid.md +52 -0
- package/demo/greta.md +50 -0
- package/demo/hugo.md +49 -0
- package/demo/iris.md +49 -0
- package/demo/jonas.md +51 -0
- package/demo/kira.md +51 -0
- package/demo/luca.md +49 -0
- package/demo/parcels.yml +92 -0
- package/demo/seed.py +20 -0
- package/demo/show.py +161 -0
- package/demo/stories.py +824 -0
- package/demo/units.yml +33 -0
- package/docs/agents-first.md +99 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +231 -0
- package/docs/self-host.md +151 -0
- package/docs/testing.md +205 -0
- package/package.json +73 -0
- package/plugin/.claude-plugin/plugin.json +62 -0
- package/plugin/.mcp.json +15 -0
- package/plugin/channel/activity-cli.ts +16 -0
- package/plugin/channel/adapter-prompts.ts +113 -0
- package/plugin/channel/adapter.js +4292 -0
- package/plugin/channel/adapter.ts +133 -0
- package/plugin/channel/bus.js +20188 -0
- package/plugin/channel/bus.ts +131 -0
- package/plugin/channel/city-config.ts +125 -0
- package/plugin/channel/claude-channel.ts +58 -0
- package/plugin/channel/cli-args.ts +75 -0
- package/plugin/channel/client.js +4200 -0
- package/plugin/channel/client.ts +38 -0
- package/plugin/channel/committee/activity.ts +256 -0
- package/plugin/channel/committee/collection.ts +154 -0
- package/plugin/channel/committee/decision.ts +175 -0
- package/plugin/channel/committee/floor.ts +135 -0
- package/plugin/channel/committee/guards.ts +30 -0
- package/plugin/channel/committee/history.ts +38 -0
- package/plugin/channel/committee/render.ts +102 -0
- package/plugin/channel/committee/service.ts +72 -0
- package/plugin/channel/committee/storage.ts +92 -0
- package/plugin/channel/committee/types.ts +163 -0
- package/plugin/channel/committee/view.ts +63 -0
- package/plugin/channel/committee-cli.ts +109 -0
- package/plugin/channel/delivery-metrics.ts +61 -0
- package/plugin/channel/delivery-queue.ts +118 -0
- package/plugin/channel/hub/activity-controller.ts +72 -0
- package/plugin/channel/hub/activity-feed.ts +169 -0
- package/plugin/channel/hub/committee-controller.ts +60 -0
- package/plugin/channel/hub/connections.ts +48 -0
- package/plugin/channel/hub/diagnostics.ts +67 -0
- package/plugin/channel/hub/envelope-validity.ts +109 -0
- package/plugin/channel/hub/envelopes.ts +66 -0
- package/plugin/channel/hub/lifecycle.ts +91 -0
- package/plugin/channel/hub/local-roads.ts +89 -0
- package/plugin/channel/hub/remote-roads.ts +165 -0
- package/plugin/channel/hub/road-controller.ts +99 -0
- package/plugin/channel/hub-client.ts +149 -0
- package/plugin/channel/local-hub.js +5995 -0
- package/plugin/channel/local-hub.ts +349 -0
- package/plugin/channel/map-reporter.ts +48 -0
- package/plugin/channel/package-lock.json +1731 -0
- package/plugin/channel/package.json +19 -0
- package/plugin/channel/protocol.ts +98 -0
- package/plugin/channel/road-cli.ts +18 -0
- package/plugin/channel/run.sh +15 -0
- package/plugin/channel/runtime/claude.ts +458 -0
- package/plugin/channel/runtime/codex-config.ts +77 -0
- package/plugin/channel/runtime/codex.ts +669 -0
- package/plugin/channel/runtime/command.ts +62 -0
- package/plugin/channel/runtime/factory.ts +15 -0
- package/plugin/channel/runtime/json-rpc.ts +154 -0
- package/plugin/channel/runtime/kimi.ts +277 -0
- package/plugin/channel/runtime/opencode.ts +248 -0
- package/plugin/channel/runtime/process.ts +84 -0
- package/plugin/channel/runtime/types.ts +45 -0
- package/plugin/channel/runtime-files.ts +73 -0
- package/plugin/channel/runtime-gateway.js +6396 -0
- package/plugin/channel/runtime-gateway.ts +358 -0
- package/plugin/channel/runtime-metrics.ts +52 -0
- package/plugin/channel/runtime-subscription.ts +180 -0
- package/plugin/channel/terminal-delivery.ts +131 -0
- package/plugin/channel/untrusted.ts +60 -0
- package/plugin/commands/committee.md +31 -0
- package/plugin/commands/exit.md +32 -0
- package/plugin/commands/goals.md +20 -0
- package/plugin/commands/join.md +18 -0
- package/plugin/commands/notice.md +20 -0
- package/plugin/commands/propose.md +19 -0
- package/plugin/commands/round.md +17 -0
- package/plugin/commands/session.md +22 -0
- package/plugin/commands/settings.md +25 -0
- package/plugin/commands/setup.md +21 -0
- package/plugin/commands/team.md +23 -0
- package/plugin/domains/custom.md +27 -0
- package/plugin/domains/finance.md +31 -0
- package/plugin/domains/healthcare.md +34 -0
- package/plugin/domains/legal.md +31 -0
- package/plugin/domains/marketing.md +33 -0
- package/plugin/domains/operations.md +31 -0
- package/plugin/domains/research.md +32 -0
- package/plugin/domains/sales.md +30 -0
- package/plugin/domains/software.md +37 -0
- package/plugin/hooks/activity.sh +6 -0
- package/plugin/hooks/digging.sh +68 -0
- package/plugin/hooks/growth.sh +42 -0
- package/plugin/hooks/hooks.json +106 -0
- package/plugin/hooks/notice-on-pr.sh +35 -0
- package/plugin/hooks/notice-on-stop.sh +80 -0
- package/plugin/hooks/notice-pending.sh +58 -0
- package/plugin/hooks/solo-en-ciudad.sh +23 -0
- package/plugin/hooks/tokens.sh +47 -0
- package/plugin/roles/examples/account-executive.md +25 -0
- package/plugin/roles/examples/ai-manager.md +34 -0
- package/plugin/roles/examples/associate.md +24 -0
- package/plugin/roles/examples/brand-lead.md +20 -0
- package/plugin/roles/examples/cfo.md +20 -0
- package/plugin/roles/examples/city-lead.md +25 -0
- package/plugin/roles/examples/clinical-director.md +26 -0
- package/plugin/roles/examples/clinical-ops.md +25 -0
- package/plugin/roles/examples/clinician.md +25 -0
- package/plugin/roles/examples/compliance.md +27 -0
- package/plugin/roles/examples/content.md +24 -0
- package/plugin/roles/examples/controller.md +27 -0
- package/plugin/roles/examples/cpto.md +41 -0
- package/plugin/roles/examples/customer-success.md +25 -0
- package/plugin/roles/examples/data-engineer.md +37 -0
- package/plugin/roles/examples/data.md +60 -0
- package/plugin/roles/examples/dev.md +37 -0
- package/plugin/roles/examples/devops.md +39 -0
- package/plugin/roles/examples/enablement.md +24 -0
- package/plugin/roles/examples/ethics.md +24 -0
- package/plugin/roles/examples/fin-analytics.md +27 -0
- package/plugin/roles/examples/health-compliance.md +25 -0
- package/plugin/roles/examples/health-data.md +26 -0
- package/plugin/roles/examples/knowledge.md +25 -0
- package/plugin/roles/examples/lifecycle.md +26 -0
- package/plugin/roles/examples/llm-engineer.md +34 -0
- package/plugin/roles/examples/managing-partner.md +20 -0
- package/plugin/roles/examples/methods.md +23 -0
- package/plugin/roles/examples/operations-lead.md +23 -0
- package/plugin/roles/examples/ops.md +24 -0
- package/plugin/roles/examples/patient-safety.md +26 -0
- package/plugin/roles/examples/performance.md +27 -0
- package/plugin/roles/examples/po.md +36 -0
- package/plugin/roles/examples/process-owner.md +23 -0
- package/plugin/roles/examples/product-design.md +39 -0
- package/plugin/roles/examples/program-manager.md +24 -0
- package/plugin/roles/examples/quality.md +25 -0
- package/plugin/roles/examples/research-director.md +24 -0
- package/plugin/roles/examples/research-ops.md +25 -0
- package/plugin/roles/examples/researcher.md +25 -0
- package/plugin/roles/examples/revenue-lead.md +25 -0
- package/plugin/roles/examples/revops.md +26 -0
- package/plugin/roles/examples/seo.md +27 -0
- package/plugin/roles/examples/specialist.md +24 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +252 -0
- package/plugin/scripts/capabilities.py +197 -0
- package/plugin/scripts/card.py +312 -0
- package/plugin/scripts/cities.py +520 -0
- package/plugin/scripts/city-env.sh +90 -0
- package/plugin/scripts/city-runtime.sh +87 -0
- package/plugin/scripts/city-session.sh +526 -0
- package/plugin/scripts/city_env.py +67 -0
- package/plugin/scripts/crecimiento.py +140 -0
- package/plugin/scripts/deliberations.py +48 -0
- package/plugin/scripts/doctor.py +172 -0
- package/plugin/scripts/domains.py +201 -0
- package/plugin/scripts/evidencia.py +64 -0
- package/plugin/scripts/find-repos.sh +111 -0
- package/plugin/scripts/gh.py +159 -0
- package/plugin/scripts/hall_protocol.py +147 -0
- package/plugin/scripts/hook_activity.py +139 -0
- package/plugin/scripts/launch.py +94 -0
- package/plugin/scripts/logs.py +98 -0
- package/plugin/scripts/pairing.py +185 -0
- package/plugin/scripts/parcels.py +132 -0
- package/plugin/scripts/read-card.py +51 -0
- package/plugin/scripts/report.py +201 -0
- package/plugin/scripts/reset.py +180 -0
- package/plugin/scripts/roads.py +230 -0
- package/plugin/scripts/roles.py +274 -0
- package/plugin/scripts/runtime_log.py +69 -0
- package/plugin/scripts/runtime_processes.py +132 -0
- package/plugin/scripts/rutas.py +83 -0
- package/plugin/scripts/seat.py +1349 -0
- package/plugin/scripts/tokens.py +194 -0
- package/plugin/scripts/trust-repos.py +59 -0
- package/plugin/scripts/ui.py +280 -0
- package/plugin/scripts/units.py +80 -0
- package/plugin/scripts/workspace.py +364 -0
- package/plugin/skills/city/SKILL.md +191 -0
- package/templates/blank.md +39 -0
- package/templates/finance.md +44 -0
- package/templates/legal.md +45 -0
- package/templates/marketing.md +48 -0
- package/templates/product.md +45 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read or follow one city's durable activity and diagnostic logs."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import city_env
|
|
10
|
+
import runtime_processes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read(path):
|
|
14
|
+
try:
|
|
15
|
+
with open(path, encoding='utf-8') as stream:
|
|
16
|
+
return [json.loads(line) for line in stream if line.strip()]
|
|
17
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def display(record, raw=False):
|
|
22
|
+
if raw:
|
|
23
|
+
print(json.dumps(record, ensure_ascii=False), flush=True)
|
|
24
|
+
return
|
|
25
|
+
at = str(record.get('at', ''))[11:19] or '--:--:--'
|
|
26
|
+
source = 'activity' if record.get('protocol') == 'agents-city-activity/1' else 'diagnostic'
|
|
27
|
+
actor = record.get('actor') or record.get('component') or 'city'
|
|
28
|
+
event = record.get('kind') or record.get('event') or 'event'
|
|
29
|
+
message = record.get('summary') or record.get('message') or record.get('outcome') or ''
|
|
30
|
+
print(f'{at} {source:<10} {str(actor):<18} {event} {message}', flush=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(): # noqa: C901 - CLI selection and follow mode share one stream lifecycle
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
description='show persistent city conversations and operational diagnostics')
|
|
36
|
+
group = parser.add_mutually_exclusive_group()
|
|
37
|
+
group.add_argument(
|
|
38
|
+
'--activity', action='store_true', help='only visible conversation/work events')
|
|
39
|
+
group.add_argument(
|
|
40
|
+
'--diagnostics', action='store_true', help='only operational diagnostics')
|
|
41
|
+
parser.add_argument('-f', '--follow', action='store_true', help='keep following new events')
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
'-n', '--lines', type=int, default=100, help='initial records (default: 100)')
|
|
44
|
+
parser.add_argument('--json', action='store_true', help='emit JSONL unchanged')
|
|
45
|
+
args = parser.parse_args()
|
|
46
|
+
|
|
47
|
+
data = city_env.datos()
|
|
48
|
+
if not data:
|
|
49
|
+
print('agents-city: no selected city', file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
directory = runtime_processes.ruta(data)
|
|
52
|
+
selected = []
|
|
53
|
+
if not args.diagnostics:
|
|
54
|
+
selected.append(os.path.join(directory, 'activity.jsonl'))
|
|
55
|
+
if not args.activity:
|
|
56
|
+
selected.append(os.path.join(directory, 'diagnostics.jsonl'))
|
|
57
|
+
|
|
58
|
+
rows = []
|
|
59
|
+
positions = {}
|
|
60
|
+
for path in selected:
|
|
61
|
+
values = read(path)
|
|
62
|
+
rows.extend(values)
|
|
63
|
+
try:
|
|
64
|
+
positions[path] = os.path.getsize(path)
|
|
65
|
+
except OSError:
|
|
66
|
+
positions[path] = 0
|
|
67
|
+
rows.sort(key=lambda row: str(row.get('at', '')))
|
|
68
|
+
for row in rows[-max(0, args.lines):]:
|
|
69
|
+
display(row, args.json)
|
|
70
|
+
if not args.follow:
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
while True:
|
|
75
|
+
for path in selected:
|
|
76
|
+
try:
|
|
77
|
+
size = os.path.getsize(path)
|
|
78
|
+
if size < positions[path]:
|
|
79
|
+
positions[path] = 0
|
|
80
|
+
if size == positions[path]:
|
|
81
|
+
continue
|
|
82
|
+
with open(path, encoding='utf-8') as stream:
|
|
83
|
+
stream.seek(positions[path])
|
|
84
|
+
for line in stream:
|
|
85
|
+
try:
|
|
86
|
+
display(json.loads(line), args.json)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
pass
|
|
89
|
+
positions[path] = stream.tell()
|
|
90
|
+
except OSError:
|
|
91
|
+
continue
|
|
92
|
+
time.sleep(.2)
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == '__main__':
|
|
98
|
+
sys.exit(main())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Road pairing: how an unknown city earns the right to write to a seat.
|
|
3
|
+
|
|
4
|
+
Exchanging invitation files works, but it is friction, and friction is why
|
|
5
|
+
nobody connects two cities. Pairing is the low-friction path: an unknown seat's
|
|
6
|
+
first message yields a short code the owner approves once. Approving grants one
|
|
7
|
+
thing only — permission for that address to send to this seat — never road
|
|
8
|
+
membership, never chair authority, never anything the code's holder did not
|
|
9
|
+
already have.
|
|
10
|
+
|
|
11
|
+
The safety comes from four constants and two rules. A code is short and
|
|
12
|
+
unambiguous (no 0/O/1/I) so it can be read aloud; it expires; only a bounded
|
|
13
|
+
number can be pending per city, so a flood cannot mint an unbounded backlog;
|
|
14
|
+
and the human code is never echoed by tooling — approvals happen by an opaque
|
|
15
|
+
id derived from it. The two rules: a pending request is created (and its code
|
|
16
|
+
revealed) at most once per sender per window, and an approval is only ever
|
|
17
|
+
consulted under the pairing policy, never to widen an allowlist.
|
|
18
|
+
|
|
19
|
+
State is one JSON file per city under its runtime dir. No network, no daemon:
|
|
20
|
+
the seat reads and writes it, the tests drive it on any platform.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import secrets
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
CODIGO_LARGO = 8
|
|
30
|
+
CODIGO_ALFABETO = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # no 0/O/1/I, read-aloud safe
|
|
31
|
+
PENDIENTE_TTL_MS = 60 * 60 * 1000 # a code is good for one hour
|
|
32
|
+
PENDIENTE_MAX = 3 # at most this many pending per city
|
|
33
|
+
CONCEDIDO_MAX = 500 # a generous cap on approved senders
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ahora_ms():
|
|
37
|
+
return int(time.time() * 1000)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _estado(datos):
|
|
41
|
+
real = os.path.realpath(os.path.expanduser(datos))
|
|
42
|
+
ruta = os.path.join(real, '.runtime')
|
|
43
|
+
os.makedirs(ruta, mode=0o700, exist_ok=True)
|
|
44
|
+
return os.path.join(ruta, 'pairing.json')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _lee(datos):
|
|
48
|
+
try:
|
|
49
|
+
with open(_estado(datos), encoding='utf-8') as f:
|
|
50
|
+
objeto = json.load(f)
|
|
51
|
+
except (OSError, json.JSONDecodeError):
|
|
52
|
+
objeto = {}
|
|
53
|
+
objeto.setdefault('pending', {})
|
|
54
|
+
objeto.setdefault('granted', {})
|
|
55
|
+
return objeto
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _escribe(datos, objeto):
|
|
59
|
+
ruta = _estado(datos)
|
|
60
|
+
tmp = f'{ruta}.tmp'
|
|
61
|
+
with open(tmp, 'w', encoding='utf-8') as f:
|
|
62
|
+
json.dump(objeto, f, indent=2, sort_keys=True)
|
|
63
|
+
os.chmod(tmp, 0o600)
|
|
64
|
+
os.replace(tmp, ruta)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _codigo():
|
|
68
|
+
return ''.join(secrets.choice(CODIGO_ALFABETO) for _ in range(CODIGO_LARGO))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def id_solicitud(direccion, codigo):
|
|
72
|
+
"""The opaque id a tool uses to approve, so the human code never travels."""
|
|
73
|
+
crudo = f'{direccion}\0{codigo}'.encode()
|
|
74
|
+
return 'pr_' + hashlib.sha256(crudo).hexdigest()[:20]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _purga(objeto, ahora):
|
|
78
|
+
objeto['pending'] = {
|
|
79
|
+
d: p for d, p in objeto['pending'].items()
|
|
80
|
+
if ahora - int(p.get('ts', 0)) < PENDIENTE_TTL_MS
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def concedido(datos, direccion):
|
|
85
|
+
"""True when this seat address has already been approved to write here."""
|
|
86
|
+
objeto = _lee(datos)
|
|
87
|
+
return direccion in objeto['granted']
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def solicita(datos, direccion):
|
|
91
|
+
"""Record a pending pairing for `direccion`, returning the code exactly once.
|
|
92
|
+
|
|
93
|
+
Returns `{"code", "id", "created": True}` the first time within the TTL, and
|
|
94
|
+
`{"id", "created": False}` on any repeat — the code is revealed once, so
|
|
95
|
+
re-messaging cannot re-spam it, and a flood cannot exceed the pending cap.
|
|
96
|
+
"""
|
|
97
|
+
objeto = _lee(datos)
|
|
98
|
+
if direccion in objeto['granted']:
|
|
99
|
+
return {'created': False, 'already': True}
|
|
100
|
+
ahora = _ahora_ms()
|
|
101
|
+
_purga(objeto, ahora)
|
|
102
|
+
existente = objeto['pending'].get(direccion)
|
|
103
|
+
if existente:
|
|
104
|
+
return {'created': False, 'id': existente['id']}
|
|
105
|
+
if len(objeto['pending']) >= PENDIENTE_MAX:
|
|
106
|
+
return {'created': False, 'full': True}
|
|
107
|
+
codigo = _codigo()
|
|
108
|
+
entrada = {'code': codigo, 'id': id_solicitud(direccion, codigo), 'ts': ahora}
|
|
109
|
+
objeto['pending'][direccion] = entrada
|
|
110
|
+
_escribe(datos, objeto)
|
|
111
|
+
return {'created': True, 'code': codigo, 'id': entrada['id']}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _concede(datos, seleccion):
|
|
115
|
+
"""Grant one pending request in a single read/write, given a picker.
|
|
116
|
+
|
|
117
|
+
`seleccion(pending) -> direccion|None` is the only thing that differs
|
|
118
|
+
between approving by code and approving by opaque id, so both entry points
|
|
119
|
+
share the read, purge, cap check, grant and write exactly once.
|
|
120
|
+
"""
|
|
121
|
+
objeto = _lee(datos)
|
|
122
|
+
ahora = _ahora_ms()
|
|
123
|
+
_purga(objeto, ahora)
|
|
124
|
+
encontrado = seleccion(objeto['pending'])
|
|
125
|
+
if not encontrado:
|
|
126
|
+
_escribe(datos, objeto)
|
|
127
|
+
return None
|
|
128
|
+
if len(objeto['granted']) >= CONCEDIDO_MAX:
|
|
129
|
+
raise ValueError('the pairing grant table is full; revoke unused senders first')
|
|
130
|
+
objeto['granted'][encontrado] = {'ts': ahora}
|
|
131
|
+
del objeto['pending'][encontrado]
|
|
132
|
+
_escribe(datos, objeto)
|
|
133
|
+
return encontrado
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _por_campo(campo, objetivo):
|
|
137
|
+
"""A picker matching one pending field in constant time — no timing signal.
|
|
138
|
+
|
|
139
|
+
The target is compared as UTF-8 bytes so a non-ASCII or non-string input
|
|
140
|
+
(e.g. a client-supplied opaque id) yields a clean no-match instead of the
|
|
141
|
+
TypeError `compare_digest` raises on non-ASCII strings.
|
|
142
|
+
"""
|
|
143
|
+
objetivo_b = str(objetivo or '').encode('utf-8')
|
|
144
|
+
|
|
145
|
+
def elige(pending):
|
|
146
|
+
hallado = None
|
|
147
|
+
for direccion, p in pending.items():
|
|
148
|
+
if secrets.compare_digest(str(p.get(campo, '')).encode('utf-8'), objetivo_b):
|
|
149
|
+
hallado = direccion
|
|
150
|
+
return hallado
|
|
151
|
+
return elige
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def aprueba(datos, codigo):
|
|
155
|
+
"""Approve a pending request by its human code. Returns the address or None.
|
|
156
|
+
|
|
157
|
+
A non-string or empty code matches nothing and returns None — the
|
|
158
|
+
documented 'a wrong code approves nothing', never a crash.
|
|
159
|
+
"""
|
|
160
|
+
return _concede(datos, _por_campo('code', str(codigo or '').strip().upper()))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def aprueba_por_id(datos, id_):
|
|
164
|
+
"""Approve by opaque id (what tooling holds), never echoing the code."""
|
|
165
|
+
return _concede(datos, _por_campo('id', id_))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def revoca(datos, direccion):
|
|
169
|
+
"""Withdraw a previously granted sender. Returns True if one was removed."""
|
|
170
|
+
objeto = _lee(datos)
|
|
171
|
+
quitado = objeto['granted'].pop(direccion, None) is not None
|
|
172
|
+
objeto['pending'].pop(direccion, None)
|
|
173
|
+
_escribe(datos, objeto)
|
|
174
|
+
return quitado
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def pendientes(datos):
|
|
178
|
+
"""Opaque view of pending requests: id + age, never the code."""
|
|
179
|
+
objeto = _lee(datos)
|
|
180
|
+
ahora = _ahora_ms()
|
|
181
|
+
_purga(objeto, ahora)
|
|
182
|
+
return [
|
|
183
|
+
{'address': d, 'id': p['id'], 'ageMinutes': (ahora - int(p['ts'])) // 60000}
|
|
184
|
+
for d, p in sorted(objeto['pending'].items())
|
|
185
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The one reader of `parcels.yml`.
|
|
3
|
+
|
|
4
|
+
There were two, and they were not the same reader. Both walked the format with the
|
|
5
|
+
same byte-identical regex, and then they disagreed: the seeder handled the `lab:`
|
|
6
|
+
section and complained about lines it could not parse; the reporter did neither,
|
|
7
|
+
and read the folder out of a key called `path` that this format has never had. So
|
|
8
|
+
the map and the growth reporter had different ideas about the same file, and the
|
|
9
|
+
reporter's idea was wrong in a way that stopped it working at all.
|
|
10
|
+
|
|
11
|
+
Two readers of one format is a bug waiting for someone to edit only one of them.
|
|
12
|
+
|
|
13
|
+
It lives here, in `plugin/scripts/`, and not next to the seeder, because this
|
|
14
|
+
directory is what gets installed on everybody's machine — the plugin ships without
|
|
15
|
+
`city/`, so the plugin cannot import from there. The seeder runs from a clone and
|
|
16
|
+
can import from here, so this is the only direction that works.
|
|
17
|
+
|
|
18
|
+
Deliberately not YAML: the format is a line at a time on purpose, so this runs
|
|
19
|
+
anywhere with no dependency to install.
|
|
20
|
+
|
|
21
|
+
repos:
|
|
22
|
+
main-site:
|
|
23
|
+
- ruta: "content/**/deposits/**" ; unidad: banking ; nombre: "main · deposits"
|
|
24
|
+
- ruta: "" ; unidad: none ; nombre: "main · platform"
|
|
25
|
+
lab: []
|
|
26
|
+
"""
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
# A parcel line. Values may be quoted, fields are separated by `;`, and a missing
|
|
30
|
+
# field is a default rather than an error.
|
|
31
|
+
CAMPO = re.compile(r'(\w+):\s*"?([^";\n]*?)"?\s*(?:;|$)')
|
|
32
|
+
CABECERA = re.compile(r'^[a-zA-Z0-9_.\-]+:\s*$') # `repos:` / `lab:`
|
|
33
|
+
REPO = re.compile(r'^ [a-zA-Z0-9_.\-]+:\s*$') # ` main-site:`
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def campos(linea):
|
|
37
|
+
"""`- ruta: "x" ; unidad: y ; nombre: "z"` -> {'ruta': 'x', …}"""
|
|
38
|
+
return dict(CAMPO.findall(linea.strip().lstrip('- ')))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def identidad(repo, ruta):
|
|
42
|
+
"""A parcel's id, which has to be computed the same way everywhere.
|
|
43
|
+
|
|
44
|
+
The reporter pushes growth keyed on this and the map stores rows keyed on this.
|
|
45
|
+
If the two ever disagree, growth lands on nothing and neither side says so.
|
|
46
|
+
"""
|
|
47
|
+
return f'{repo}:{ruta}' if ruta else repo
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def lee(ruta_fichero):
|
|
51
|
+
"""Walk the file once. Returns (parcelas, lab, raras).
|
|
52
|
+
|
|
53
|
+
parcelas: [{'id', 'repo', 'ruta', 'unidad', 'nombre'}] in file order
|
|
54
|
+
lab: {repo, …} listed under `lab:`
|
|
55
|
+
raras: lines under a repo that are not parcels — the caller decides whether
|
|
56
|
+
to complain, because a silent one is how a repo ends up in the wrong
|
|
57
|
+
district for a reason nobody can find
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
with open(ruta_fichero, encoding='utf-8') as f:
|
|
61
|
+
txt = f.read()
|
|
62
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
63
|
+
return [], set(), []
|
|
64
|
+
|
|
65
|
+
fuera, lab, raras = [], set(), []
|
|
66
|
+
repo, en_lab = None, False
|
|
67
|
+
for linea in txt.splitlines():
|
|
68
|
+
if re.match(r'^lab:\s*$', linea):
|
|
69
|
+
en_lab, repo = True, None
|
|
70
|
+
continue
|
|
71
|
+
if CABECERA.match(linea):
|
|
72
|
+
en_lab = False
|
|
73
|
+
continue
|
|
74
|
+
if REPO.match(linea):
|
|
75
|
+
repo = linea.strip().rstrip(':')
|
|
76
|
+
elif linea.strip().startswith('- ruta:') and repo:
|
|
77
|
+
c = campos(linea)
|
|
78
|
+
r = c.get('ruta', '')
|
|
79
|
+
fuera.append({'id': identidad(repo, r), 'repo': repo, 'ruta': r,
|
|
80
|
+
'unidad': c.get('unidad', 'none'),
|
|
81
|
+
'nombre': c.get('nombre', repo)})
|
|
82
|
+
elif linea.strip().startswith('- '):
|
|
83
|
+
suelto = linea.strip().lstrip('- ').strip()
|
|
84
|
+
if en_lab:
|
|
85
|
+
if suelto:
|
|
86
|
+
lab.add(suelto)
|
|
87
|
+
elif repo:
|
|
88
|
+
raras.append(linea.strip()[:70])
|
|
89
|
+
return fuera, lab, raras
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def escribe(ruta_fichero, porRepo, lab=()):
|
|
93
|
+
"""Write the whole file back, in the shape `lee` reads.
|
|
94
|
+
|
|
95
|
+
`porRepo` is {repo: [{'ruta', 'unidad', 'nombre'}, …]}. This exists so the
|
|
96
|
+
modelling — the one thing no tool can do for you — can be edited with a form
|
|
97
|
+
instead of by hand in a format nobody remembers. Round-tripping through `lee`
|
|
98
|
+
is pinned by a test, because a writer that drifts from its reader is exactly
|
|
99
|
+
the bug this module was created to end.
|
|
100
|
+
"""
|
|
101
|
+
lineas = ['# Which slice of which repo serves which unit.',
|
|
102
|
+
'#',
|
|
103
|
+
'# A house is not a repo: it is a **parcel**, a slice of a repo serving one',
|
|
104
|
+
'# unit. One repo can hold several, and that is the central fact of most',
|
|
105
|
+
'# codebases.',
|
|
106
|
+
'',
|
|
107
|
+
'repos:']
|
|
108
|
+
for repo in sorted(porRepo):
|
|
109
|
+
filas = porRepo[repo] or [{'ruta': '', 'unidad': 'none', 'nombre': repo}]
|
|
110
|
+
lineas.append(f' {repo}:')
|
|
111
|
+
for f in filas:
|
|
112
|
+
ruta = str(f.get('ruta', '')).replace(';', '').replace('"', '')
|
|
113
|
+
unidad = str(f.get('unidad', 'none')).replace(';', '') or 'none'
|
|
114
|
+
nombre = str(f.get('nombre') or repo).replace(';', ',').replace('"', "'")
|
|
115
|
+
lineas.append(f' - ruta: "{ruta}" ; unidad: {unidad} ; nombre: "{nombre}"')
|
|
116
|
+
lineas += ['', '# Research that does not ship yet gets its own district instead of',
|
|
117
|
+
'# reading as waste among the rest.']
|
|
118
|
+
lineas.append('lab:' if lab else 'lab: []')
|
|
119
|
+
for r in sorted(lab):
|
|
120
|
+
lineas.append(f' - {r}')
|
|
121
|
+
with open(ruta_fichero, 'w', encoding='utf-8') as f:
|
|
122
|
+
f.write('\n'.join(lineas) + '\n')
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def aviso(raras):
|
|
126
|
+
"""What to say about lines that could not be read, or '' if there were none."""
|
|
127
|
+
if not raras:
|
|
128
|
+
return ''
|
|
129
|
+
lineas = [f'parcels.yml: {len(raras)} line(s) I could not read — they are being '
|
|
130
|
+
f'ignored. Use: - ruta: "<glob>" ; unidad: <unit> ; nombre: "<name>"']
|
|
131
|
+
lineas += [f' {r}' for r in raras[:5]]
|
|
132
|
+
return '\n'.join(lineas)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read one field out of a card's frontmatter, for the shell scripts.
|
|
3
|
+
|
|
4
|
+
read-card.py <card.md> agent
|
|
5
|
+
read-card.py <card.md> repos -> comma-separated list
|
|
6
|
+
read-card.py --window <repo> -> canonical tmux/bus actor slug
|
|
7
|
+
read-card.py --actor-role <card.md> <actor> -> safe operating role or blank
|
|
8
|
+
|
|
9
|
+
A thin front on card.py, which is where the parsing lives. It used to hold its own
|
|
10
|
+
copy, and that copy is why the tmux session asked for `agente` — a field no card in
|
|
11
|
+
this repo has ever carried — and got nothing every single time.
|
|
12
|
+
|
|
13
|
+
`agente` still answers, for a card somebody wrote by hand.
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
19
|
+
import card # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
if len(sys.argv) == 3 and sys.argv[1] == '--window':
|
|
24
|
+
print(card.ventana(sys.argv[2]))
|
|
25
|
+
return
|
|
26
|
+
if len(sys.argv) == 4 and sys.argv[1] == '--actor-role':
|
|
27
|
+
try:
|
|
28
|
+
texto = open(sys.argv[2], encoding='utf-8').read()
|
|
29
|
+
except OSError as e:
|
|
30
|
+
sys.exit(f'cannot read {sys.argv[2]}: {e}')
|
|
31
|
+
actor = card.ventana(sys.argv[3])
|
|
32
|
+
print(card.rol_seguro(card.campo(texto, f'role.{actor}')))
|
|
33
|
+
return
|
|
34
|
+
if len(sys.argv) < 3:
|
|
35
|
+
sys.exit(
|
|
36
|
+
'usage: read-card.py <card.md> <field> | --window <repo> | '
|
|
37
|
+
'--actor-role <card.md> <actor>'
|
|
38
|
+
)
|
|
39
|
+
ruta, campo = sys.argv[1], sys.argv[2]
|
|
40
|
+
try:
|
|
41
|
+
with open(ruta, encoding='utf-8') as f:
|
|
42
|
+
texto = f.read()
|
|
43
|
+
except OSError as e:
|
|
44
|
+
sys.exit(f'cannot read {ruta}: {e}')
|
|
45
|
+
valor = card.campo(texto, campo)
|
|
46
|
+
# `repos` comes back as a list everywhere else; the shell wants one line.
|
|
47
|
+
print(','.join(card.lista(valor)) if campo == 'repos' else valor)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == '__main__':
|
|
51
|
+
main()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Report growth from where the work actually happens.
|
|
3
|
+
|
|
4
|
+
The city's cron can count merged pull requests by itself. It cannot run a command
|
|
5
|
+
inside your folders — and in a marketing, legal or finance city, growth is
|
|
6
|
+
whatever a command in that folder returns: pieces published, documents filed,
|
|
7
|
+
periods closed.
|
|
8
|
+
|
|
9
|
+
So this runs where the folders are, once a day is plenty, and reports the totals.
|
|
10
|
+
The city works out what changed today.
|
|
11
|
+
|
|
12
|
+
./bin/report.py show what it would send
|
|
13
|
+
./bin/report.py --push send it
|
|
14
|
+
./bin/report.py --data ../mine read a different data repo
|
|
15
|
+
|
|
16
|
+
Nothing to run by hand in the normal case: the plugin reports once a day from a
|
|
17
|
+
Stop hook. This is here for the first look, and for a backfill.
|
|
18
|
+
|
|
19
|
+
It needs your bus token, because the city does not take numbers from anonymous
|
|
20
|
+
callers: `CITY_BUS_TOKEN`, or the plugin's keychain entry, or `--token`.
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import urllib.request
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
import city_env
|
|
32
|
+
import parcels # the one reader of parcels.yml, shared with the seeder
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def lee(fichero, clave, defecto=''):
|
|
36
|
+
try:
|
|
37
|
+
t = open(fichero).read()
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return defecto
|
|
40
|
+
m = re.search(rf'^{clave}:\s*(.+)$', t, re.M)
|
|
41
|
+
return m.group(1).strip() if m else defecto
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_DONDE = {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def donde_esta(repo):
|
|
48
|
+
"""The local folder of a repo, by the name on its remote.
|
|
49
|
+
|
|
50
|
+
`parcels.yml` deliberately holds no local paths: the same file is shared by a
|
|
51
|
+
whole team and the same repo sits somewhere different on every machine. That is
|
|
52
|
+
what find-repos.sh is for, and it caches its index for a day.
|
|
53
|
+
"""
|
|
54
|
+
if repo not in _DONDE:
|
|
55
|
+
guion = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'find-repos.sh')
|
|
56
|
+
try:
|
|
57
|
+
r = subprocess.run([guion, repo], capture_output=True, text=True, timeout=300)
|
|
58
|
+
_DONDE[repo] = r.stdout.strip()
|
|
59
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
60
|
+
_DONDE[repo] = ''
|
|
61
|
+
return _DONDE[repo]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parcelas(datos):
|
|
65
|
+
"""Every parcel, with the folder to run its grow command in.
|
|
66
|
+
|
|
67
|
+
The format is read by `parcels.py`, which the seeder reads too. All this adds is
|
|
68
|
+
the one thing that is local to this machine: where the folder actually is.
|
|
69
|
+
|
|
70
|
+
The folder used to be read as `c.get('path')` — a key this format does not have
|
|
71
|
+
and no writer has ever written; it is `ruta`. So it was always empty, every
|
|
72
|
+
parcel was skipped as folderless, and this script could not report a single
|
|
73
|
+
number for anybody. The message it printed sent you to add a `path:` that means
|
|
74
|
+
nothing.
|
|
75
|
+
|
|
76
|
+
`ruta` is a glob *inside* the repo, not a path to it. A concrete subdirectory
|
|
77
|
+
becomes the folder; a glob, or an empty one, means the whole repo, because there
|
|
78
|
+
is no single directory a pattern points at.
|
|
79
|
+
"""
|
|
80
|
+
declaradas, _, raras = parcels.lee(f'{datos}/parcels.yml')
|
|
81
|
+
if raras:
|
|
82
|
+
print(parcels.aviso(raras), file=sys.stderr)
|
|
83
|
+
fuera = []
|
|
84
|
+
for p in declaradas:
|
|
85
|
+
base = donde_esta(p['repo'])
|
|
86
|
+
carpeta = base
|
|
87
|
+
if base and p['ruta'] and not any(c in p['ruta'] for c in '*?['):
|
|
88
|
+
dentro = os.path.join(base, p['ruta'])
|
|
89
|
+
if os.path.isdir(dentro):
|
|
90
|
+
carpeta = dentro
|
|
91
|
+
fuera.append(dict(p, path=carpeta))
|
|
92
|
+
return fuera
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cuenta(comando, cwd):
|
|
96
|
+
"""Run the grow command and take the first number it prints.
|
|
97
|
+
|
|
98
|
+
A command that fails counts as *unknown*, not as zero. Reporting zero because
|
|
99
|
+
a script broke would look like work being undone, which is worse than a gap.
|
|
100
|
+
"""
|
|
101
|
+
if not comando:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
r = subprocess.run(comando, shell=True, cwd=cwd, capture_output=True,
|
|
105
|
+
text=True, timeout=120)
|
|
106
|
+
except subprocess.TimeoutExpired:
|
|
107
|
+
return None
|
|
108
|
+
if r.returncode != 0:
|
|
109
|
+
return None
|
|
110
|
+
m = re.search(r'-?\d+', r.stdout.replace(',', ''))
|
|
111
|
+
return int(m.group()) if m else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def mide(datos, por_defecto, todas):
|
|
115
|
+
"""Run each parcel's grow command where it lives. A command that fails is
|
|
116
|
+
*unknown*, never zero — zero would look like work being undone."""
|
|
117
|
+
filas, sin_medir = [], []
|
|
118
|
+
for p in todas:
|
|
119
|
+
carpeta = os.path.expanduser(p['path']) if p['path'] else None
|
|
120
|
+
if not carpeta or not os.path.isdir(carpeta):
|
|
121
|
+
continue
|
|
122
|
+
# The folder's own descriptor wins over the city default: a parcel knows
|
|
123
|
+
# better than the city how it is counted.
|
|
124
|
+
cmd = lee(os.path.join(carpeta, '.city.yml'), 'grow_command', por_defecto)
|
|
125
|
+
n = cuenta(cmd, carpeta)
|
|
126
|
+
if n is None:
|
|
127
|
+
sin_medir.append(p['id'])
|
|
128
|
+
else:
|
|
129
|
+
filas.append({'id': p['id'], 'floors': n})
|
|
130
|
+
return filas, sin_medir
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def explica_vacio(di, datos, todas, por_defecto):
|
|
134
|
+
"""Nothing measured: say which of the three reasons it is, precisely. The
|
|
135
|
+
version before this printed advice about a field that does not exist."""
|
|
136
|
+
perdidas = [p['repo'] for p in todas if not p['path']]
|
|
137
|
+
di(f'Nothing to report from {datos}.')
|
|
138
|
+
if not todas:
|
|
139
|
+
di('There are no parcels in parcels.yml yet. ./bin/seat writes one per '
|
|
140
|
+
'folder you pick.')
|
|
141
|
+
elif perdidas:
|
|
142
|
+
di(f'{len(perdidas)} of {len(todas)} parcels are not cloned on this '
|
|
143
|
+
f'machine, so there is no folder to run anything in:')
|
|
144
|
+
di(' ' + ', '.join(sorted(set(perdidas))[:10]))
|
|
145
|
+
di('Clone them, or run ./plugin/scripts/find-repos.sh --refresh if they '
|
|
146
|
+
'are here under another folder name.')
|
|
147
|
+
elif not por_defecto:
|
|
148
|
+
di('The folders are here, but nothing says how growth is counted. Put '
|
|
149
|
+
'`grow_command` in city.yml, or a `.city.yml` in each folder.')
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main():
|
|
153
|
+
ap = argparse.ArgumentParser()
|
|
154
|
+
ap.add_argument('--data', default=city_env.datos())
|
|
155
|
+
ap.add_argument('--url', default=city_env.url())
|
|
156
|
+
ap.add_argument('--token', default='')
|
|
157
|
+
ap.add_argument('--push', action='store_true')
|
|
158
|
+
ap.add_argument('--quiet', action='store_true', help='say nothing unless it fails')
|
|
159
|
+
a = ap.parse_args()
|
|
160
|
+
di = (lambda *x, **k: None) if a.quiet else print
|
|
161
|
+
datos = os.path.expanduser(a.data)
|
|
162
|
+
|
|
163
|
+
por_defecto = lee(f'{datos}/city.yml', 'grow_command')
|
|
164
|
+
todas = parcelas(datos)
|
|
165
|
+
filas, sin_medir = mide(datos, por_defecto, todas)
|
|
166
|
+
|
|
167
|
+
if not filas and not sin_medir:
|
|
168
|
+
explica_vacio(di, datos, todas, por_defecto)
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
for f in filas:
|
|
172
|
+
di(f" {f['id']:<34} {f['floors']}")
|
|
173
|
+
for s in sin_medir:
|
|
174
|
+
di(f' {s:<34} — could not measure (command failed or returned nothing)')
|
|
175
|
+
|
|
176
|
+
if not a.push:
|
|
177
|
+
di('\nNothing sent. Add --push when it looks right.')
|
|
178
|
+
return 0
|
|
179
|
+
if not a.url:
|
|
180
|
+
print('\nNo city URL. Pass --url or set AGENTS_CITY_URL.', file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
tk = city_env.token(a.token)
|
|
183
|
+
if not tk:
|
|
184
|
+
print('\nNo bus token. Pass --token or set CITY_BUS_TOKEN.', file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
req = urllib.request.Request(
|
|
188
|
+
a.url.rstrip('/') + '/api/growth',
|
|
189
|
+
data=json.dumps({'parcels': filas}).encode(),
|
|
190
|
+
headers={'content-type': 'application/json', 'authorization': f'Bearer {tk}'})
|
|
191
|
+
try:
|
|
192
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
193
|
+
di('\n' + r.read().decode())
|
|
194
|
+
except Exception as e:
|
|
195
|
+
print(f'\nThe city refused it: {e}', file=sys.stderr)
|
|
196
|
+
return 1
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == '__main__':
|
|
201
|
+
sys.exit(main())
|