devcouncil 0.3.0 → 0.4.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.
- package/README.md +46 -30
- package/package.json +6 -2
- package/packages/codeintel-grammars/hatch_build.py +43 -0
- package/packages/codeintel-grammars/pyproject.toml +16 -0
- package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
- package/pyproject.toml +99 -4
- package/src/devcouncil/app/config.py +512 -20
- package/src/devcouncil/app/events.py +4 -23
- package/src/devcouncil/app/orchestrator.py +5 -0
- package/src/devcouncil/app/run_context.py +3 -3
- package/src/devcouncil/assets/__init__.py +4 -1
- package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
- package/src/devcouncil/campaign/__init__.py +71 -0
- package/src/devcouncil/campaign/bloom.py +137 -0
- package/src/devcouncil/campaign/dashboard.py +123 -0
- package/src/devcouncil/campaign/mailbox.py +305 -0
- package/src/devcouncil/campaign/notify.py +91 -0
- package/src/devcouncil/campaign/orchestrator.py +592 -0
- package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
- package/src/devcouncil/campaign/prompts/director.md +21 -0
- package/src/devcouncil/campaign/prompts/protocol.md +46 -0
- package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
- package/src/devcouncil/campaign/prompts/worker.md +24 -0
- package/src/devcouncil/campaign/roles.py +202 -0
- package/src/devcouncil/campaign/watcher.py +153 -0
- package/src/devcouncil/cli/commands/agents.py +24 -17
- package/src/devcouncil/cli/commands/artifacts.py +36 -27
- package/src/devcouncil/cli/commands/ast.py +12 -3
- package/src/devcouncil/cli/commands/baseline.py +21 -12
- package/src/devcouncil/cli/commands/boot.py +218 -0
- package/src/devcouncil/cli/commands/campaign.py +302 -0
- package/src/devcouncil/cli/commands/check.py +225 -12
- package/src/devcouncil/cli/commands/config.py +221 -74
- package/src/devcouncil/cli/commands/cost.py +137 -28
- package/src/devcouncil/cli/commands/dashboard.py +12 -4
- package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
- package/src/devcouncil/cli/commands/design.py +27 -17
- package/src/devcouncil/cli/commands/doctor.py +790 -8
- package/src/devcouncil/cli/commands/evidence.py +41 -20
- package/src/devcouncil/cli/commands/export.py +73 -0
- package/src/devcouncil/cli/commands/gaps.py +175 -0
- package/src/devcouncil/cli/commands/gated_write.py +76 -0
- package/src/devcouncil/cli/commands/go.py +220 -68
- package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
- package/src/devcouncil/cli/commands/handoff.py +45 -34
- package/src/devcouncil/cli/commands/hook.py +630 -85
- package/src/devcouncil/cli/commands/init.py +89 -30
- package/src/devcouncil/cli/commands/integrate.py +296 -1385
- package/src/devcouncil/cli/commands/lease.py +120 -0
- package/src/devcouncil/cli/commands/logs.py +12 -5
- package/src/devcouncil/cli/commands/lsp.py +40 -5
- package/src/devcouncil/cli/commands/map.py +317 -74
- package/src/devcouncil/cli/commands/mcp_server.py +12 -2
- package/src/devcouncil/cli/commands/okf.py +44 -6
- package/src/devcouncil/cli/commands/plan.py +184 -69
- package/src/devcouncil/cli/commands/prompt.py +26 -17
- package/src/devcouncil/cli/commands/provenance.py +79 -0
- package/src/devcouncil/cli/commands/repair.py +60 -49
- package/src/devcouncil/cli/commands/report.py +148 -40
- package/src/devcouncil/cli/commands/requirements.py +104 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
- package/src/devcouncil/cli/commands/rollback.py +46 -35
- package/src/devcouncil/cli/commands/run.py +173 -8
- package/src/devcouncil/cli/commands/runs.py +298 -68
- package/src/devcouncil/cli/commands/scaffold.py +33 -12
- package/src/devcouncil/cli/commands/semantic.py +29 -14
- package/src/devcouncil/cli/commands/setup.py +103 -93
- package/src/devcouncil/cli/commands/shell.py +51 -42
- package/src/devcouncil/cli/commands/show.py +56 -42
- package/src/devcouncil/cli/commands/skills.py +29 -20
- package/src/devcouncil/cli/commands/status.py +80 -67
- package/src/devcouncil/cli/commands/task_gate.py +295 -0
- package/src/devcouncil/cli/commands/tasks.py +248 -19
- package/src/devcouncil/cli/commands/trace.py +14 -8
- package/src/devcouncil/cli/commands/verify.py +33 -8
- package/src/devcouncil/cli/commands/version.py +14 -6
- package/src/devcouncil/cli/commands/watch.py +49 -30
- package/src/devcouncil/cli/commands/watch_fs.py +30 -19
- package/src/devcouncil/cli/commands/wiki.py +278 -0
- package/src/devcouncil/cli/main.py +58 -1
- package/src/devcouncil/codeintel/__init__.py +16 -0
- package/src/devcouncil/codeintel/build_control.py +429 -0
- package/src/devcouncil/codeintel/build_worker.py +78 -0
- package/src/devcouncil/codeintel/debug/__init__.py +17 -0
- package/src/devcouncil/codeintel/debug/broker.py +114 -0
- package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
- package/src/devcouncil/codeintel/debug/consent.py +36 -0
- package/src/devcouncil/codeintel/debug/discovery.py +132 -0
- package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
- package/src/devcouncil/codeintel/debug/protocol.py +259 -0
- package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
- package/src/devcouncil/codeintel/debug/session.py +238 -0
- package/src/devcouncil/codeintel/debug/tracing.py +201 -0
- package/src/devcouncil/codeintel/languages/__init__.py +17 -0
- package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
- package/src/devcouncil/codeintel/languages/registry.py +149 -0
- package/src/devcouncil/codeintel/languages/workers.py +245 -0
- package/src/devcouncil/codeintel/query/__init__.py +5 -0
- package/src/devcouncil/codeintel/query/engine.py +289 -0
- package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
- package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
- package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
- package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
- package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
- package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
- package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
- package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
- package/src/devcouncil/codeintel/service.py +104 -0
- package/src/devcouncil/codeintel/store/__init__.py +15 -0
- package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
- package/src/devcouncil/codeintel/sync/__init__.py +19 -0
- package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
- package/src/devcouncil/codeintel/sync/incremental.py +484 -0
- package/src/devcouncil/codeintel/sync/lease.py +96 -0
- package/src/devcouncil/codeintel/sync/scope.py +98 -0
- package/src/devcouncil/council/__init__.py +4 -0
- package/src/devcouncil/council/prompts/__init__.py +4 -0
- package/src/devcouncil/domain/checkpoint_refs.py +17 -0
- package/src/devcouncil/domain/evidence.py +1 -0
- package/src/devcouncil/domain/gap.py +10 -0
- package/src/devcouncil/domain/requirement.py +5 -1
- package/src/devcouncil/domain/task.py +43 -2
- package/src/devcouncil/execution/checkpoints.py +25 -31
- package/src/devcouncil/execution/context_builder.py +15 -44
- package/src/devcouncil/execution/fs_watcher.py +64 -0
- package/src/devcouncil/execution/gated_write.py +203 -0
- package/src/devcouncil/execution/handoff.py +2 -1
- package/src/devcouncil/execution/hook_policy.py +19 -5
- package/src/devcouncil/execution/lease_ops.py +177 -0
- package/src/devcouncil/execution/lease_validation.py +71 -0
- package/src/devcouncil/execution/patch.py +3 -0
- package/src/devcouncil/execution/permissions.py +1 -0
- package/src/devcouncil/execution/policy_engine.py +205 -10
- package/src/devcouncil/execution/prompt_builder.py +278 -33
- package/src/devcouncil/execution/run_trace.py +356 -0
- package/src/devcouncil/execution/shell_session.py +46 -5
- package/src/devcouncil/execution/stop_gate.py +746 -0
- package/src/devcouncil/execution/stop_gate_history.py +113 -0
- package/src/devcouncil/execution/stop_gate_state.py +54 -0
- package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
- package/src/devcouncil/execution/task_gate_ops.py +590 -0
- package/src/devcouncil/execution/task_runner.py +19 -0
- package/src/devcouncil/executors/advisor_tool.py +315 -0
- package/src/devcouncil/executors/agent_registry.py +125 -17
- package/src/devcouncil/executors/claude_sdk.py +376 -0
- package/src/devcouncil/executors/coding_cli.py +724 -25
- package/src/devcouncil/executors/mini_swe.py +50 -8
- package/src/devcouncil/executors/native/agent.py +224 -19
- package/src/devcouncil/executors/openhands.py +50 -8
- package/src/devcouncil/executors/transient_retry.py +99 -0
- package/src/devcouncil/gating/checks/clean_git.py +5 -2
- package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
- package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
- package/src/devcouncil/gating/policy.py +46 -2
- package/src/devcouncil/indexing/ast_matcher.py +41 -4
- package/src/devcouncil/indexing/graph/__init__.py +78 -0
- package/src/devcouncil/indexing/graph/api_routes.py +522 -0
- package/src/devcouncil/indexing/graph/build.py +862 -0
- package/src/devcouncil/indexing/graph/cache.py +329 -0
- package/src/devcouncil/indexing/graph/communities.py +28 -0
- package/src/devcouncil/indexing/graph/cypher.py +107 -0
- package/src/devcouncil/indexing/graph/embeddings.py +194 -0
- package/src/devcouncil/indexing/graph/export.py +381 -0
- package/src/devcouncil/indexing/graph/export_links.py +81 -0
- package/src/devcouncil/indexing/graph/extract_python.py +307 -0
- package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
- package/src/devcouncil/indexing/graph/intel.py +668 -0
- package/src/devcouncil/indexing/graph/liveness.py +992 -0
- package/src/devcouncil/indexing/graph/okf_export.py +65 -0
- package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
- package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
- package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
- package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
- package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
- package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
- package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
- package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
- package/src/devcouncil/indexing/graph/query.py +302 -0
- package/src/devcouncil/indexing/graph/resolve.py +1020 -0
- package/src/devcouncil/indexing/graph/schema.py +103 -0
- package/src/devcouncil/indexing/graph_index.py +20 -29
- package/src/devcouncil/indexing/lsp.py +57 -25
- package/src/devcouncil/indexing/lsp_client.py +577 -0
- package/src/devcouncil/indexing/map_artifacts.py +355 -0
- package/src/devcouncil/indexing/map_refresh.py +141 -0
- package/src/devcouncil/indexing/repo_mapper.py +1509 -138
- package/src/devcouncil/indexing/semantic_index.py +12 -6
- package/src/devcouncil/indexing/subsystem_map.py +163 -0
- package/src/devcouncil/indexing/ts_imports.py +343 -0
- package/src/devcouncil/indexing/viz.py +960 -0
- package/src/devcouncil/indexing/walk.py +52 -0
- package/src/devcouncil/indexing/wiring.py +1776 -0
- package/src/devcouncil/integrations/actions.py +27 -4
- package/src/devcouncil/integrations/check.py +211 -16
- package/src/devcouncil/integrations/claude_assets.py +209 -12
- package/src/devcouncil/integrations/clients/__init__.py +1 -0
- package/src/devcouncil/integrations/clients/aider.py +52 -0
- package/src/devcouncil/integrations/clients/antigravity.py +87 -0
- package/src/devcouncil/integrations/clients/claude.py +339 -0
- package/src/devcouncil/integrations/clients/codex.py +39 -0
- package/src/devcouncil/integrations/clients/common.py +332 -0
- package/src/devcouncil/integrations/clients/cursor.py +164 -0
- package/src/devcouncil/integrations/clients/gemini.py +49 -0
- package/src/devcouncil/integrations/clients/grok.py +105 -0
- package/src/devcouncil/integrations/clients/hooks.py +500 -0
- package/src/devcouncil/integrations/clients/opencode.py +96 -0
- package/src/devcouncil/integrations/clients/warp.py +75 -0
- package/src/devcouncil/integrations/code_review_graph.py +2 -2
- package/src/devcouncil/integrations/github.py +73 -7
- package/src/devcouncil/integrations/integration_cli.py +197 -0
- package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
- package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
- package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
- package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
- package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
- package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
- package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
- package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
- package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
- package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
- package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
- package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
- package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
- package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
- package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
- package/src/devcouncil/integrations/mcp/server.py +265 -2391
- package/src/devcouncil/integrations/mcp/util.py +303 -0
- package/src/devcouncil/integrations/setup.py +152 -0
- package/src/devcouncil/knowledge/fetch.py +4 -0
- package/src/devcouncil/knowledge/knowledge_select.py +38 -0
- package/src/devcouncil/knowledge/okf.py +2 -1
- package/src/devcouncil/knowledge/resource_discovery.py +40 -0
- package/src/devcouncil/knowledge/wiki.py +643 -0
- package/src/devcouncil/knowledge/wiki_read.py +87 -0
- package/src/devcouncil/live/cards.py +7 -7
- package/src/devcouncil/live/models.py +4 -1
- package/src/devcouncil/live/reviewer.py +90 -11
- package/src/devcouncil/live/signals.py +4 -2
- package/src/devcouncil/live/tasks.py +12 -3
- package/src/devcouncil/live/transcripts.py +69 -2
- package/src/devcouncil/llm/cache.py +5 -6
- package/src/devcouncil/llm/model_defaults.yaml +10 -10
- package/src/devcouncil/llm/provider.py +647 -73
- package/src/devcouncil/llm/router.py +271 -46
- package/src/devcouncil/llm/semantic_bridge.py +614 -0
- package/src/devcouncil/optimization/gepa_agent.py +6 -4
- package/src/devcouncil/optimization/skillopt.py +9 -5
- package/src/devcouncil/planning/arbiter_service.py +12 -3
- package/src/devcouncil/planning/correction_manifest.py +107 -10
- package/src/devcouncil/planning/plan_difficulty.py +69 -0
- package/src/devcouncil/planning/plan_service.py +5 -2
- package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
- package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
- package/src/devcouncil/planning/question_conversion.py +56 -0
- package/src/devcouncil/planning/spec_service.py +9 -3
- package/src/devcouncil/repo/ci_scaffold.py +197 -1
- package/src/devcouncil/repo/gitignore.py +1 -2
- package/src/devcouncil/reporting/evidence_export.py +124 -0
- package/src/devcouncil/reporting/evidence_html.py +210 -0
- package/src/devcouncil/reporting/json_report.py +16 -12
- package/src/devcouncil/reporting/markdown_report.py +38 -9
- package/src/devcouncil/reporting/mcp_resources.py +142 -0
- package/src/devcouncil/reporting/report_builder.py +40 -4
- package/src/devcouncil/reporting/task_provenance.py +42 -0
- package/src/devcouncil/reporting/verdict.py +75 -0
- package/src/devcouncil/skills/library/README.md +1 -0
- package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
- package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
- package/src/devcouncil/skills/library/devcouncil.md +93 -0
- package/src/devcouncil/skills/registry.py +43 -12
- package/src/devcouncil/storage/db.py +57 -11
- package/src/devcouncil/storage/models.py +6 -0
- package/src/devcouncil/storage/native.py +5 -3
- package/src/devcouncil/storage/repositories.py +50 -18
- package/src/devcouncil/telemetry/context.py +28 -0
- package/src/devcouncil/telemetry/cost.py +4 -5
- package/src/devcouncil/telemetry/logging_setup.py +78 -11
- package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
- package/src/devcouncil/telemetry/stages.py +27 -2
- package/src/devcouncil/telemetry/tracker.py +50 -13
- package/src/devcouncil/ui/dashboard.py +120 -8
- package/src/devcouncil/utils/fsio.py +58 -0
- package/src/devcouncil/utils/git_snapshot.py +112 -0
- package/src/devcouncil/utils/json_persist.py +53 -0
- package/src/devcouncil/utils/proc.py +89 -0
- package/src/devcouncil/verification/acceptance_compiler.py +36 -13
- package/src/devcouncil/verification/ad_hoc_check.py +95 -3
- package/src/devcouncil/verification/checks/__init__.py +41 -0
- package/src/devcouncil/verification/checks/acceptance.py +39 -0
- package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
- package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
- package/src/devcouncil/verification/checks/command_evidence.py +148 -0
- package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
- package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
- package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
- package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
- package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
- package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
- package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
- package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
- package/src/devcouncil/verification/checks/planned_files.py +98 -0
- package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
- package/src/devcouncil/verification/checks/stale_map.py +80 -0
- package/src/devcouncil/verification/checks/stub_scan.py +71 -0
- package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
- package/src/devcouncil/verification/checks/wiring.py +216 -0
- package/src/devcouncil/verification/claims/__init__.py +23 -0
- package/src/devcouncil/verification/claims/checks.py +395 -0
- package/src/devcouncil/verification/claims/mapper.py +168 -0
- package/src/devcouncil/verification/claims/models.py +39 -0
- package/src/devcouncil/verification/claims/transcript.py +92 -0
- package/src/devcouncil/verification/claims/verdict.py +88 -0
- package/src/devcouncil/verification/command_evidence.py +170 -0
- package/src/devcouncil/verification/command_malformation.py +147 -0
- package/src/devcouncil/verification/command_runner.py +164 -0
- package/src/devcouncil/verification/coverage_measurement.py +292 -0
- package/src/devcouncil/verification/diff_coverage.py +151 -0
- package/src/devcouncil/verification/difficulty.py +296 -0
- package/src/devcouncil/verification/effort_heuristics.py +178 -0
- package/src/devcouncil/verification/gap_ids.py +63 -0
- package/src/devcouncil/verification/gate_cache.py +194 -0
- package/src/devcouncil/verification/gate_selector.py +344 -0
- package/src/devcouncil/verification/git_diff_fallback.py +272 -0
- package/src/devcouncil/verification/implementation_reviewer.py +13 -0
- package/src/devcouncil/verification/incremental_check.py +241 -0
- package/src/devcouncil/verification/next_actions.py +60 -1
- package/src/devcouncil/verification/rigor_analytics.py +130 -0
- package/src/devcouncil/verification/sandbox.py +38 -11
- package/src/devcouncil/verification/stub_detector.py +369 -0
- package/src/devcouncil/verification/test_resolver.py +67 -1
- package/src/devcouncil/verification/verifier.py +137 -1666
- package/src/devcouncil/verification/verify_orchestration.py +610 -0
- package/src/devcouncil/verification/verify_setup.py +176 -0
- package/src/devcouncil/verification/wiki_refresh.py +208 -0
- package/src/semantic_layer/__init__.py +58 -0
- package/src/semantic_layer/benchmark.py +75 -0
- package/src/semantic_layer/cache.py +290 -0
- package/src/semantic_layer/compressor.py +137 -0
- package/src/semantic_layer/config.py +75 -0
- package/src/semantic_layer/embeddings.py +69 -0
- package/src/semantic_layer/llm_backends.py +99 -0
- package/src/semantic_layer/pipeline.py +111 -0
- package/src/semantic_layer/router.py +128 -0
- package/src/semantic_layer/tuner.py +72 -0
- package/uv.lock +973 -9
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/integrations/gitnexus.py +0 -70
- package/src/devcouncil/integrations/graphify.py +0 -34
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Bloom's-taxonomy routing — decide whether a task needs a worker or a thinker.
|
|
2
|
+
|
|
3
|
+
The Coordinator tags every subtask with a Bloom level and routes it: the lower three
|
|
4
|
+
levels (Remember / Understand / Apply) are execution and go to an **Worker**;
|
|
5
|
+
the upper three (Analyze / Evaluate / Create) are cognition and go to the
|
|
6
|
+
**Reviewer**. The classifier is a deterministic keyword heuristic — intentionally
|
|
7
|
+
simple, cheap and testable — with an explicit override honoured first so a
|
|
8
|
+
planner or human can pin a level via ``Task.difficulty`` or a tag.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from enum import IntEnum
|
|
15
|
+
from typing import Dict, Iterable, List, Optional
|
|
16
|
+
|
|
17
|
+
from devcouncil.campaign.roles import Rank
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BloomLevel(IntEnum):
|
|
21
|
+
REMEMBER = 1
|
|
22
|
+
UNDERSTAND = 2
|
|
23
|
+
APPLY = 3
|
|
24
|
+
ANALYZE = 4
|
|
25
|
+
EVALUATE = 5
|
|
26
|
+
CREATE = 6
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def label(self) -> str:
|
|
30
|
+
return self.name.capitalize()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Ordered high→low so a strong verb ("architect", L6) wins over an incidental
|
|
34
|
+
# weak one ("list", L1) appearing in the same sentence.
|
|
35
|
+
_KEYWORDS: Dict[BloomLevel, List[str]] = {
|
|
36
|
+
BloomLevel.CREATE: [
|
|
37
|
+
"design", "architect", "architecture", "invent", "compose", "author",
|
|
38
|
+
"scaffold new", "green-field", "greenfield", "propose", "strategy",
|
|
39
|
+
"strategize", "plan the", "new subsystem", "from scratch",
|
|
40
|
+
],
|
|
41
|
+
BloomLevel.EVALUATE: [
|
|
42
|
+
"evaluate", "assess", "review", "audit", "critique", "judge", "compare",
|
|
43
|
+
"trade-off", "tradeoff", "recommend", "decide", "prioritize", "quality",
|
|
44
|
+
"verify design", "root cause", "root-cause",
|
|
45
|
+
],
|
|
46
|
+
BloomLevel.ANALYZE: [
|
|
47
|
+
"analyze", "analyse", "investigate", "diagnose", "debug", "profile",
|
|
48
|
+
"why", "reverse-engineer", "break down", "correlate", "trace",
|
|
49
|
+
"distinguish", "differentiate",
|
|
50
|
+
],
|
|
51
|
+
BloomLevel.APPLY: [
|
|
52
|
+
"implement", "build", "write", "add", "create ", "fix", "refactor",
|
|
53
|
+
"wire", "integrate", "migrate", "port", "configure", "hook up",
|
|
54
|
+
"apply", "use", "run", "execute",
|
|
55
|
+
],
|
|
56
|
+
BloomLevel.UNDERSTAND: [
|
|
57
|
+
"summarize", "summarise", "explain", "describe", "document", "clarify",
|
|
58
|
+
"interpret", "outline", "paraphrase", "restate",
|
|
59
|
+
],
|
|
60
|
+
BloomLevel.REMEMBER: [
|
|
61
|
+
"list", "find", "locate", "look up", "fetch", "collect", "gather",
|
|
62
|
+
"identify", "name", "retrieve", "read",
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Strong execution verbs — when present, cap classification at APPLY even if
|
|
67
|
+
# evaluate/analyze keywords ("review", "quality", …) also appear in the title.
|
|
68
|
+
_IMPLEMENTATION_OVERRIDES: List[str] = [
|
|
69
|
+
"implement", "fix", "add", "write", "build", "refactor", "wire", "integrate",
|
|
70
|
+
"migrate", "port", "configure", "hook up",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_DIFFICULTY_HINT: Dict[str, BloomLevel] = {
|
|
75
|
+
"easy": BloomLevel.APPLY,
|
|
76
|
+
"normal": BloomLevel.APPLY,
|
|
77
|
+
"hard": BloomLevel.ANALYZE,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _match(text: str, phrase: str) -> bool:
|
|
82
|
+
if phrase.endswith(" ") or " " in phrase:
|
|
83
|
+
return phrase.strip() in text
|
|
84
|
+
return re.search(rf"\b{re.escape(phrase)}\b", text) is not None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classify_bloom(
|
|
88
|
+
text: str,
|
|
89
|
+
*,
|
|
90
|
+
override: Optional[BloomLevel] = None,
|
|
91
|
+
difficulty: Optional[str] = None,
|
|
92
|
+
) -> BloomLevel:
|
|
93
|
+
"""Classify free text into a :class:`BloomLevel`.
|
|
94
|
+
|
|
95
|
+
Precedence: explicit ``override`` > keyword match > ``difficulty`` hint >
|
|
96
|
+
default (:attr:`BloomLevel.APPLY`, the most common execution level).
|
|
97
|
+
"""
|
|
98
|
+
if override is not None:
|
|
99
|
+
return override
|
|
100
|
+
haystack = (text or "").lower()
|
|
101
|
+
for level in sorted(_KEYWORDS, reverse=True): # CREATE(6) → REMEMBER(1)
|
|
102
|
+
if level is BloomLevel.APPLY:
|
|
103
|
+
continue
|
|
104
|
+
if any(_match(haystack, kw) for kw in _KEYWORDS[level]):
|
|
105
|
+
if level >= BloomLevel.EVALUATE and any(
|
|
106
|
+
_match(haystack, kw) for kw in _IMPLEMENTATION_OVERRIDES
|
|
107
|
+
):
|
|
108
|
+
return BloomLevel.APPLY
|
|
109
|
+
return level
|
|
110
|
+
if any(_match(haystack, kw) for kw in _KEYWORDS[BloomLevel.APPLY]):
|
|
111
|
+
return BloomLevel.APPLY
|
|
112
|
+
if difficulty and difficulty.lower() in _DIFFICULTY_HINT:
|
|
113
|
+
return _DIFFICULTY_HINT[difficulty.lower()]
|
|
114
|
+
return BloomLevel.APPLY
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def route_rank(level: BloomLevel) -> Rank:
|
|
118
|
+
"""Map a Bloom level to the rank that should own it."""
|
|
119
|
+
return Rank.REVIEWER if level >= BloomLevel.ANALYZE else Rank.WORKER
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def route_text(
|
|
123
|
+
text: str,
|
|
124
|
+
*,
|
|
125
|
+
override: Optional[BloomLevel] = None,
|
|
126
|
+
difficulty: Optional[str] = None,
|
|
127
|
+
) -> Rank:
|
|
128
|
+
"""Convenience: classify ``text`` and return the owning rank."""
|
|
129
|
+
return route_rank(classify_bloom(text, override=override, difficulty=difficulty))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def summarize_routing(items: Iterable[str]) -> Dict[str, int]:
|
|
133
|
+
"""Count how a batch of task descriptions would route (for the dashboard)."""
|
|
134
|
+
counts = {Rank.WORKER.value: 0, Rank.REVIEWER.value: 0}
|
|
135
|
+
for item in items:
|
|
136
|
+
counts[route_text(item).value] += 1
|
|
137
|
+
return counts
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The campaign dashboard — the Coordinator's single source of truth for the operator.
|
|
2
|
+
|
|
3
|
+
Only the Coordinator writes ``.devcouncil/campaign/dashboard.md``. It renders the roster,
|
|
4
|
+
the order in play, work in progress, achievements (verified tasks) and anything
|
|
5
|
+
blocked, so the operator (or the Director answering for the operator) can read the state of
|
|
6
|
+
the campaign at a glance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RosterEntry:
|
|
19
|
+
agent: str
|
|
20
|
+
rank: str
|
|
21
|
+
status: str = "idle" # idle | working | reviewing
|
|
22
|
+
current_task: str = "-"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class DashboardState:
|
|
27
|
+
"""Everything the dashboard renders — mutated by the campaign as it runs."""
|
|
28
|
+
|
|
29
|
+
goal: str = ""
|
|
30
|
+
roster: List[RosterEntry] = field(default_factory=list)
|
|
31
|
+
in_progress: List[str] = field(default_factory=list) # "T-001 · worker1 · Apply"
|
|
32
|
+
achievements: List[str] = field(default_factory=list) # verified task lines
|
|
33
|
+
blocked: List[str] = field(default_factory=list) # "T-002 — <gap>"
|
|
34
|
+
skipped: List[str] = field(default_factory=list) # unmet dependency
|
|
35
|
+
routing: Dict[str, int] = field(default_factory=dict)
|
|
36
|
+
total_tasks: int = 0
|
|
37
|
+
completed_tasks: int = 0
|
|
38
|
+
cost_usd: float = 0.0
|
|
39
|
+
cost_budget_usd: float | None = None
|
|
40
|
+
|
|
41
|
+
def roster_for(self, agent: str) -> Optional[RosterEntry]:
|
|
42
|
+
for entry in self.roster:
|
|
43
|
+
if entry.agent == agent:
|
|
44
|
+
return entry
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DashboardWriter:
|
|
49
|
+
"""Renders :class:`DashboardState` to markdown and writes it atomically-ish."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, root: Path | str = Path(".")):
|
|
52
|
+
self.root = Path(root)
|
|
53
|
+
self.path = self.root / ".devcouncil" / "campaign" / "dashboard.md"
|
|
54
|
+
|
|
55
|
+
def render(self, state: DashboardState) -> str:
|
|
56
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
|
57
|
+
lines: List[str] = []
|
|
58
|
+
lines.append("# Director Campaign Dashboard")
|
|
59
|
+
lines.append("")
|
|
60
|
+
lines.append(f"_Updated {now} — written by the Coordinator._")
|
|
61
|
+
lines.append("")
|
|
62
|
+
lines.append("## Order")
|
|
63
|
+
lines.append("")
|
|
64
|
+
lines.append(f"> {state.goal or '(none)'}")
|
|
65
|
+
lines.append("")
|
|
66
|
+
if state.routing:
|
|
67
|
+
routed = ", ".join(f"{k}: {v}" for k, v in state.routing.items())
|
|
68
|
+
lines.append(f"Routing — {routed}")
|
|
69
|
+
lines.append("")
|
|
70
|
+
|
|
71
|
+
if state.total_tasks:
|
|
72
|
+
pct = int(100 * state.completed_tasks / state.total_tasks) if state.total_tasks else 0
|
|
73
|
+
lines.append("## Progress")
|
|
74
|
+
lines.append("")
|
|
75
|
+
lines.append(f"- Tasks: **{state.completed_tasks}/{state.total_tasks}** verified ({pct}%)")
|
|
76
|
+
if state.cost_budget_usd is not None:
|
|
77
|
+
lines.append(
|
|
78
|
+
f"- Spend: **${state.cost_usd:.4f}** / ${state.cost_budget_usd:.2f} budget"
|
|
79
|
+
)
|
|
80
|
+
elif state.cost_usd:
|
|
81
|
+
lines.append(f"- Spend: **${state.cost_usd:.4f}**")
|
|
82
|
+
lines.append("")
|
|
83
|
+
|
|
84
|
+
lines.append("## Roster")
|
|
85
|
+
lines.append("")
|
|
86
|
+
lines.append("| Agent | Rank | Status | Current |")
|
|
87
|
+
lines.append("| --- | --- | --- | --- |")
|
|
88
|
+
for e in state.roster:
|
|
89
|
+
lines.append(f"| {e.agent} | {e.rank} | {e.status} | {e.current_task} |")
|
|
90
|
+
lines.append("")
|
|
91
|
+
|
|
92
|
+
lines.append(f"## In Progress ({len(state.in_progress)})")
|
|
93
|
+
lines.append("")
|
|
94
|
+
lines.extend(f"- {row}" for row in state.in_progress)
|
|
95
|
+
if not state.in_progress:
|
|
96
|
+
lines.append("- (quiet)")
|
|
97
|
+
lines.append("")
|
|
98
|
+
|
|
99
|
+
lines.append(f"## Achievements ({len(state.achievements)})")
|
|
100
|
+
lines.append("")
|
|
101
|
+
lines.extend(f"- ✅ {row}" for row in state.achievements)
|
|
102
|
+
if not state.achievements:
|
|
103
|
+
lines.append("- (none yet)")
|
|
104
|
+
lines.append("")
|
|
105
|
+
|
|
106
|
+
if state.blocked:
|
|
107
|
+
lines.append(f"## Blocked ({len(state.blocked)})")
|
|
108
|
+
lines.append("")
|
|
109
|
+
lines.extend(f"- ⛔ {row}" for row in state.blocked)
|
|
110
|
+
lines.append("")
|
|
111
|
+
|
|
112
|
+
if state.skipped:
|
|
113
|
+
lines.append(f"## Skipped — unmet dependencies ({len(state.skipped)})")
|
|
114
|
+
lines.append("")
|
|
115
|
+
lines.extend(f"- ⏭️ {row}" for row in state.skipped)
|
|
116
|
+
lines.append("")
|
|
117
|
+
|
|
118
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
119
|
+
|
|
120
|
+
def write(self, state: DashboardState) -> Path:
|
|
121
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
self.path.write_text(self.render(state), encoding="utf-8")
|
|
123
|
+
return self.path
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""File-based agent mailbox — the Director message bus.
|
|
2
|
+
|
|
3
|
+
Faithful port of the original ``scripts/inbox_write.sh`` semantics:
|
|
4
|
+
|
|
5
|
+
* one YAML file per agent at ``.devcouncil/campaign/inbox/<agent>.yaml`` holding a
|
|
6
|
+
``messages:`` list of ``{id, from, timestamp, type, content, read}`` entries;
|
|
7
|
+
* every append happens under a cross-process lock (an ``mkdir`` spin-mutex, plus
|
|
8
|
+
``fcntl.flock`` where available) so concurrent Worker never corrupt a file;
|
|
9
|
+
* writes are atomic (temp file + :func:`os.replace`) so a reader never observes a
|
|
10
|
+
half-written file;
|
|
11
|
+
* the file is capped (all unread + newest ``MAX_READ_RETAINED`` read) so a busy
|
|
12
|
+
campaign cannot grow a mailbox without bound;
|
|
13
|
+
* self-sends are rejected (an agent may not mail itself).
|
|
14
|
+
|
|
15
|
+
Delivery is considered guaranteed the instant the write succeeds — there are no
|
|
16
|
+
ACKs or retries. Waking the recipient is a separate concern handled by
|
|
17
|
+
:mod:`devcouncil.campaign.watcher`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
|
|
33
|
+
try: # POSIX advisory lock; optional, we still hold the mkdir spin-mutex without it.
|
|
34
|
+
import fcntl
|
|
35
|
+
except ImportError: # pragma: no cover - Windows
|
|
36
|
+
fcntl = None # type: ignore[assignment]
|
|
37
|
+
|
|
38
|
+
MAX_MESSAGES = 50
|
|
39
|
+
"""Hard cap on messages retained per mailbox file."""
|
|
40
|
+
|
|
41
|
+
MAX_READ_RETAINED = 30
|
|
42
|
+
"""When capping, keep every unread message plus this many newest read ones."""
|
|
43
|
+
|
|
44
|
+
LOCK_TIMEOUT_S = 5.0
|
|
45
|
+
"""How long :meth:`Mailbox._lock` spins before giving up on the mutex."""
|
|
46
|
+
|
|
47
|
+
# Message types the watcher consumes directly (context resets, model swaps, …).
|
|
48
|
+
# They are delivered but excluded from the "N unread" nudge count so they never
|
|
49
|
+
# masquerade as work waiting for the agent.
|
|
50
|
+
SPECIAL_TYPES = frozenset({"clear_command", "model_switch", "cli_restart"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _utcnow() -> str:
|
|
54
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Message:
|
|
59
|
+
"""One entry in an agent's mailbox."""
|
|
60
|
+
|
|
61
|
+
id: str
|
|
62
|
+
from_agent: str
|
|
63
|
+
timestamp: str
|
|
64
|
+
type: str
|
|
65
|
+
content: str
|
|
66
|
+
read: bool = False
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> Dict[str, object]:
|
|
69
|
+
# Persist with the original schema's ``from`` key (a Python keyword, hence
|
|
70
|
+
# the ``from_agent`` attribute name).
|
|
71
|
+
return {
|
|
72
|
+
"id": self.id,
|
|
73
|
+
"from": self.from_agent,
|
|
74
|
+
"timestamp": self.timestamp,
|
|
75
|
+
"type": self.type,
|
|
76
|
+
"content": self.content,
|
|
77
|
+
"read": self.read,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, raw: Dict[str, object]) -> "Message":
|
|
82
|
+
return cls(
|
|
83
|
+
id=str(raw.get("id", "")),
|
|
84
|
+
from_agent=str(raw.get("from", raw.get("from_agent", ""))),
|
|
85
|
+
timestamp=str(raw.get("timestamp", "")),
|
|
86
|
+
type=str(raw.get("type", "info")),
|
|
87
|
+
content=str(raw.get("content", "")),
|
|
88
|
+
read=bool(raw.get("read", False)),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_special(self) -> bool:
|
|
93
|
+
return self.type in SPECIAL_TYPES
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class MailboxError(RuntimeError):
|
|
97
|
+
"""Raised when a mailbox invariant is violated (e.g. a self-send)."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MailboxLockTimeout(MailboxError):
|
|
101
|
+
"""Raised when the mailbox mutex cannot be acquired within :data:`LOCK_TIMEOUT_S`."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Mailbox:
|
|
105
|
+
"""Read/write access to every agent's on-disk inbox.
|
|
106
|
+
|
|
107
|
+
A single :class:`Mailbox` instance is safe to share across threads; all
|
|
108
|
+
mutation goes through the per-agent file lock.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, root: Path | str = Path(".")):
|
|
112
|
+
self.root = Path(root)
|
|
113
|
+
self.inbox_dir = self.root / ".devcouncil" / "campaign" / "inbox"
|
|
114
|
+
|
|
115
|
+
# -- paths -----------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def path_for(self, agent: str) -> Path:
|
|
118
|
+
return self.inbox_dir / f"{agent}.yaml"
|
|
119
|
+
|
|
120
|
+
def _ensure_dir(self) -> None:
|
|
121
|
+
self.inbox_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
|
|
123
|
+
# -- locking ---------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
class _Lock:
|
|
126
|
+
"""``with mailbox._lock(agent):`` — mkdir spin-mutex + optional flock."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, path: Path):
|
|
129
|
+
self._mutex_dir = path.with_suffix(path.suffix + ".lock.d")
|
|
130
|
+
self._flock_path = path.with_suffix(path.suffix + ".flock")
|
|
131
|
+
self._flock_fd: Optional[int] = None
|
|
132
|
+
self._acquired = False
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> "Mailbox._Lock":
|
|
135
|
+
deadline = time.monotonic() + LOCK_TIMEOUT_S
|
|
136
|
+
self._mutex_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
self._mutex_dir.mkdir() # atomic on POSIX + Windows
|
|
140
|
+
self._acquired = True
|
|
141
|
+
break
|
|
142
|
+
except FileExistsError:
|
|
143
|
+
if time.monotonic() > deadline:
|
|
144
|
+
# Best-effort stale-lock recovery: if the mutex dir is very old,
|
|
145
|
+
# assume a crashed writer left it behind and try once more.
|
|
146
|
+
try:
|
|
147
|
+
age = time.time() - self._mutex_dir.stat().st_mtime
|
|
148
|
+
if age > LOCK_TIMEOUT_S * 2:
|
|
149
|
+
self._mutex_dir.rmdir()
|
|
150
|
+
continue
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
raise MailboxLockTimeout(
|
|
154
|
+
f"could not acquire mailbox lock within {LOCK_TIMEOUT_S}s: {self._mutex_dir}"
|
|
155
|
+
)
|
|
156
|
+
time.sleep(0.02)
|
|
157
|
+
if fcntl is not None:
|
|
158
|
+
try:
|
|
159
|
+
self._flock_fd = os.open(self._flock_path, os.O_CREAT | os.O_RDWR, 0o644)
|
|
160
|
+
fcntl.flock(self._flock_fd, fcntl.LOCK_EX)
|
|
161
|
+
except OSError:
|
|
162
|
+
self._flock_fd = None
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *exc: object) -> None:
|
|
166
|
+
if self._flock_fd is not None:
|
|
167
|
+
try:
|
|
168
|
+
if fcntl is not None:
|
|
169
|
+
fcntl.flock(self._flock_fd, fcntl.LOCK_UN)
|
|
170
|
+
os.close(self._flock_fd)
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
self._flock_fd = None
|
|
174
|
+
if self._acquired:
|
|
175
|
+
try:
|
|
176
|
+
self._mutex_dir.rmdir()
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def _lock(self, agent: str) -> "Mailbox._Lock":
|
|
181
|
+
self._ensure_dir()
|
|
182
|
+
return Mailbox._Lock(self.path_for(agent))
|
|
183
|
+
|
|
184
|
+
# -- io --------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _read_raw(self, agent: str) -> List[Message]:
|
|
187
|
+
path = self.path_for(agent)
|
|
188
|
+
if not path.exists():
|
|
189
|
+
return []
|
|
190
|
+
try:
|
|
191
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
192
|
+
except (yaml.YAMLError, OSError):
|
|
193
|
+
return []
|
|
194
|
+
raw = data.get("messages", []) if isinstance(data, dict) else []
|
|
195
|
+
return [Message.from_dict(m) for m in raw if isinstance(m, dict)]
|
|
196
|
+
|
|
197
|
+
def _write_atomic(self, agent: str, messages: List[Message]) -> None:
|
|
198
|
+
self._ensure_dir()
|
|
199
|
+
path = self.path_for(agent)
|
|
200
|
+
payload = {"agent": agent, "messages": [m.to_dict() for m in messages]}
|
|
201
|
+
text = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
|
|
202
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.inbox_dir), prefix=f".{agent}.", suffix=".tmp")
|
|
203
|
+
try:
|
|
204
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
205
|
+
fh.write(text)
|
|
206
|
+
fh.flush()
|
|
207
|
+
os.fsync(fh.fileno())
|
|
208
|
+
os.replace(tmp, path) # atomic swap
|
|
209
|
+
finally:
|
|
210
|
+
if os.path.exists(tmp):
|
|
211
|
+
os.unlink(tmp)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _cap(messages: List[Message]) -> List[Message]:
|
|
215
|
+
if len(messages) <= MAX_MESSAGES:
|
|
216
|
+
return messages
|
|
217
|
+
unread = [m for m in messages if not m.read]
|
|
218
|
+
read = [m for m in messages if m.read]
|
|
219
|
+
kept_read = read[-MAX_READ_RETAINED:]
|
|
220
|
+
# Preserve original ordering (oldest→newest) while dropping the oldest read.
|
|
221
|
+
keep = set(id(m) for m in unread) | set(id(m) for m in kept_read)
|
|
222
|
+
result = [m for m in messages if id(m) in keep]
|
|
223
|
+
if len(result) > MAX_MESSAGES:
|
|
224
|
+
# Unread dominated the cap — drop oldest messages to honor MAX_MESSAGES.
|
|
225
|
+
result = result[-MAX_MESSAGES:]
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
# -- public api ------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def send(
|
|
231
|
+
self,
|
|
232
|
+
target: str,
|
|
233
|
+
content: str,
|
|
234
|
+
type: str = "info",
|
|
235
|
+
from_agent: str = "director",
|
|
236
|
+
) -> Message:
|
|
237
|
+
"""Append a message to ``target``'s mailbox and return it.
|
|
238
|
+
|
|
239
|
+
Raises :class:`MailboxError` on a self-send.
|
|
240
|
+
"""
|
|
241
|
+
if target == from_agent:
|
|
242
|
+
raise MailboxError(f"{from_agent} may not send mail to itself")
|
|
243
|
+
message = Message(
|
|
244
|
+
id=uuid.uuid4().hex[:12],
|
|
245
|
+
from_agent=from_agent,
|
|
246
|
+
timestamp=_utcnow(),
|
|
247
|
+
type=type,
|
|
248
|
+
content=content,
|
|
249
|
+
read=False,
|
|
250
|
+
)
|
|
251
|
+
with self._lock(target):
|
|
252
|
+
messages = self._read_raw(target)
|
|
253
|
+
messages.append(message)
|
|
254
|
+
self._write_atomic(target, self._cap(messages))
|
|
255
|
+
return message
|
|
256
|
+
|
|
257
|
+
def all(self, agent: str) -> List[Message]:
|
|
258
|
+
"""Every message currently in ``agent``'s mailbox (oldest→newest)."""
|
|
259
|
+
return self._read_raw(agent)
|
|
260
|
+
|
|
261
|
+
def unread(self, agent: str) -> List[Message]:
|
|
262
|
+
"""Unread messages in delivery order."""
|
|
263
|
+
return [m for m in self._read_raw(agent) if not m.read]
|
|
264
|
+
|
|
265
|
+
def count_unread(self, agent: str, exclude_special: bool = True) -> int:
|
|
266
|
+
"""Number of unread messages — the ``N`` in an ``inboxN`` nudge."""
|
|
267
|
+
msgs = self.unread(agent)
|
|
268
|
+
if exclude_special:
|
|
269
|
+
msgs = [m for m in msgs if not m.is_special]
|
|
270
|
+
return len(msgs)
|
|
271
|
+
|
|
272
|
+
def mark_read(self, agent: str, ids: Optional[List[str]] = None) -> int:
|
|
273
|
+
"""Mark messages read. ``ids=None`` marks all. Returns count changed."""
|
|
274
|
+
target_ids = set(ids) if ids is not None else None
|
|
275
|
+
changed = 0
|
|
276
|
+
with self._lock(agent):
|
|
277
|
+
messages = self._read_raw(agent)
|
|
278
|
+
for m in messages:
|
|
279
|
+
if not m.read and (target_ids is None or m.id in target_ids):
|
|
280
|
+
m.read = True
|
|
281
|
+
changed += 1
|
|
282
|
+
if changed:
|
|
283
|
+
self._write_atomic(agent, messages)
|
|
284
|
+
return changed
|
|
285
|
+
|
|
286
|
+
def drain(self, agent: str, exclude_special: bool = False) -> List[Message]:
|
|
287
|
+
"""Return unread messages and mark them read in one locked pass."""
|
|
288
|
+
with self._lock(agent):
|
|
289
|
+
messages = self._read_raw(agent)
|
|
290
|
+
picked: List[Message] = []
|
|
291
|
+
for m in messages:
|
|
292
|
+
if m.read:
|
|
293
|
+
continue
|
|
294
|
+
if exclude_special and m.is_special:
|
|
295
|
+
continue
|
|
296
|
+
m.read = True
|
|
297
|
+
picked.append(m)
|
|
298
|
+
if picked:
|
|
299
|
+
self._write_atomic(agent, messages)
|
|
300
|
+
return picked
|
|
301
|
+
|
|
302
|
+
def clear(self, agent: str) -> None:
|
|
303
|
+
"""Reset an agent's mailbox (used by campaign ``--clean``)."""
|
|
304
|
+
with self._lock(agent):
|
|
305
|
+
self._write_atomic(agent, [])
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Notifications to the operator — a port of the original ``ntfy.sh`` push.
|
|
2
|
+
|
|
3
|
+
The Coordinator (and only the Coordinator/Director) may reach the operator. When a campaign finishes
|
|
4
|
+
— or a task is blocked — a one-line push goes to an `ntfy <https://ntfy.sh>`_
|
|
5
|
+
topic so the operator can watch progress from a phone. A missing topic degrades to
|
|
6
|
+
a silent no-op; a failed push never crashes a campaign.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Notifier:
|
|
16
|
+
"""Best-effort push notifier backed by ntfy.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
topic:
|
|
21
|
+
ntfy topic. Falls back to ``$DIRECTOR_NTFY_TOPIC``. Empty → disabled.
|
|
22
|
+
server:
|
|
23
|
+
ntfy server base URL (default the public ``https://ntfy.sh``). Falls back
|
|
24
|
+
to ``$DIRECTOR_NTFY_SERVER``.
|
|
25
|
+
enabled:
|
|
26
|
+
Force-enable/disable; ``None`` means "enabled iff a topic is set".
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
topic: Optional[str] = None,
|
|
32
|
+
server: Optional[str] = None,
|
|
33
|
+
enabled: Optional[bool] = None,
|
|
34
|
+
):
|
|
35
|
+
self.topic = topic or os.environ.get("DIRECTOR_NTFY_TOPIC", "")
|
|
36
|
+
self.server = (server or os.environ.get("DIRECTOR_NTFY_SERVER") or "https://ntfy.sh").rstrip("/")
|
|
37
|
+
self.enabled = bool(self.topic) if enabled is None else enabled
|
|
38
|
+
# Records every push (delivered or not) for the dashboard / tests.
|
|
39
|
+
self.sent: List[str] = []
|
|
40
|
+
|
|
41
|
+
def notify(
|
|
42
|
+
self,
|
|
43
|
+
message: str,
|
|
44
|
+
title: Optional[str] = None,
|
|
45
|
+
priority: Optional[str] = None,
|
|
46
|
+
tags: Optional[List[str]] = None,
|
|
47
|
+
) -> bool:
|
|
48
|
+
"""Send a push. Returns ``True`` if it left the machine, else ``False``."""
|
|
49
|
+
self.sent.append(message)
|
|
50
|
+
if not self.enabled or not self.topic:
|
|
51
|
+
return False
|
|
52
|
+
try: # httpx is a DevCouncil dependency; import lazily to keep startup cheap.
|
|
53
|
+
import httpx
|
|
54
|
+
|
|
55
|
+
headers = {}
|
|
56
|
+
if title:
|
|
57
|
+
headers["Title"] = title
|
|
58
|
+
if priority:
|
|
59
|
+
headers["Priority"] = priority
|
|
60
|
+
if tags:
|
|
61
|
+
headers["Tags"] = ",".join(tags)
|
|
62
|
+
token = os.environ.get("DIRECTOR_NTFY_TOKEN")
|
|
63
|
+
if token:
|
|
64
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
65
|
+
resp = httpx.post(
|
|
66
|
+
f"{self.server}/{self.topic}",
|
|
67
|
+
content=message.encode("utf-8"),
|
|
68
|
+
headers=headers,
|
|
69
|
+
timeout=5.0,
|
|
70
|
+
)
|
|
71
|
+
return resp.status_code < 400
|
|
72
|
+
except Exception:
|
|
73
|
+
# Never let a notification failure abort the campaign.
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NullNotifier(Notifier):
|
|
78
|
+
"""A notifier that records messages but never sends — the default."""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
super().__init__(topic="", enabled=False)
|
|
82
|
+
|
|
83
|
+
def notify(
|
|
84
|
+
self,
|
|
85
|
+
message: str,
|
|
86
|
+
title: Optional[str] = None,
|
|
87
|
+
priority: Optional[str] = None,
|
|
88
|
+
tags: Optional[List[str]] = None,
|
|
89
|
+
) -> bool:
|
|
90
|
+
self.sent.append(message)
|
|
91
|
+
return False
|