devcouncil 0.3.1 → 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,356 @@
|
|
|
1
|
+
"""Shepherd-style reversible run traces: one view over a run's events + checkpoints.
|
|
2
|
+
|
|
3
|
+
DevCouncil already records the three ingredients of a reversible agent run:
|
|
4
|
+
|
|
5
|
+
* **manifests** — ``.devcouncil/runs/<run-id>/agent-run.json`` (executor, status, task);
|
|
6
|
+
* **trace events** — ``.devcouncil/logs/traces.jsonl`` (:mod:`devcouncil.telemetry.traces`);
|
|
7
|
+
* **git checkpoints** — before/after/attempt refs + patches
|
|
8
|
+
(:class:`devcouncil.execution.checkpoints.CheckpointService`).
|
|
9
|
+
|
|
10
|
+
What was missing — and what Shepherd (shepherd-agents/shepherd) argues for — is treating
|
|
11
|
+
the run itself as an object another agent can operate on: inspect the full timeline,
|
|
12
|
+
see exactly what the run changed, and revert it, behind one programming model. This
|
|
13
|
+
module joins the three stores into a :class:`RunTimeline` and provides the meta-agent
|
|
14
|
+
operations on top: :func:`revert_run` (reverse the run's workspace effects via its git
|
|
15
|
+
checkpoints) and :func:`supervise_run` (an LLM meta-agent that reviews the timeline +
|
|
16
|
+
diff and issues a keep/revert/repair verdict, degrading to deterministic heuristics
|
|
17
|
+
when no model is configured).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING, Literal, Optional
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, Field
|
|
28
|
+
|
|
29
|
+
from devcouncil.execution.checkpoints import CheckpointResult, CheckpointService
|
|
30
|
+
from devcouncil.telemetry.traces import TraceEvent, TraceLogger, read_trace_events
|
|
31
|
+
from devcouncil.utils.json_persist import read_json
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from devcouncil.llm.router import ModelRouter
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Event types that indicate the run itself went wrong (not merely that work remains).
|
|
39
|
+
_FAILURE_EVENT_TYPES = frozenset({
|
|
40
|
+
"llm_structured_parse_repair_failed",
|
|
41
|
+
"llm_provider_request_failed_fallback",
|
|
42
|
+
"task_failed",
|
|
43
|
+
"verification_failed",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
_SUPERVISOR_SYSTEM = (
|
|
47
|
+
"You are a supervisor meta-agent reviewing one recorded run of a coding agent. "
|
|
48
|
+
"You are given the run manifest, its trace-event timeline, and the workspace diff "
|
|
49
|
+
"the run produced. Decide whether its changes should be kept, reverted, or "
|
|
50
|
+
"repaired (kept but with follow-up fixes). Judge only from the evidence given; "
|
|
51
|
+
"an exit code of 0 with a plausible diff is normally 'keep'. Recommend 'revert' "
|
|
52
|
+
"only when the evidence shows the run failed, was cut off, or made changes that "
|
|
53
|
+
"contradict its task."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RunCheckpoint(BaseModel):
|
|
58
|
+
"""One git checkpoint ref recorded for the run's task."""
|
|
59
|
+
|
|
60
|
+
stage: str # before | after | attempt-N
|
|
61
|
+
ref: str
|
|
62
|
+
sha: str = ""
|
|
63
|
+
patch_path: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RunTimeline(BaseModel):
|
|
67
|
+
"""A run as an inspectable object: manifest + events + checkpoints + diff."""
|
|
68
|
+
|
|
69
|
+
run_id: str = ""
|
|
70
|
+
task_id: str = ""
|
|
71
|
+
manifest: dict = Field(default_factory=dict)
|
|
72
|
+
events: list[TraceEvent] = Field(default_factory=list)
|
|
73
|
+
checkpoints: list[RunCheckpoint] = Field(default_factory=list)
|
|
74
|
+
diff_stat: str = ""
|
|
75
|
+
reversible: bool = False
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def status(self) -> str:
|
|
79
|
+
return str(self.manifest.get("status") or "")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def returncode(self) -> Optional[int]:
|
|
83
|
+
rc = self.manifest.get("returncode")
|
|
84
|
+
return rc if isinstance(rc, int) else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SupervisorVerdict(BaseModel):
|
|
88
|
+
"""The supervisor meta-agent's decision about a run."""
|
|
89
|
+
|
|
90
|
+
verdict: Literal["keep", "revert", "repair"] = "keep"
|
|
91
|
+
confidence: float = Field(0.5, ge=0.0, le=1.0)
|
|
92
|
+
rationale: str = ""
|
|
93
|
+
findings: list[str] = Field(default_factory=list)
|
|
94
|
+
source: str = "heuristic" # heuristic | model
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- Resolution -------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _runs_dir(project_root: Path) -> Path:
|
|
101
|
+
return project_root / ".devcouncil" / "runs"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _load_manifest(project_root: Path, run_id: str) -> dict:
|
|
105
|
+
path = _runs_dir(project_root) / run_id / "agent-run.json"
|
|
106
|
+
try:
|
|
107
|
+
data = read_json(path)
|
|
108
|
+
return data if isinstance(data, dict) else {}
|
|
109
|
+
except (OSError, json.JSONDecodeError):
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_run(project_root: Path, ref: str) -> tuple[str, str]:
|
|
114
|
+
"""Resolve ``ref`` (a run id or a task id) to ``(run_id, task_id)``.
|
|
115
|
+
|
|
116
|
+
A run id is matched by manifest directory; a task id is matched against the
|
|
117
|
+
newest run manifest recorded for that task (empty run_id when the task only has
|
|
118
|
+
checkpoints and no recorded run). Either element may be empty, but not both.
|
|
119
|
+
"""
|
|
120
|
+
ref = ref.strip()
|
|
121
|
+
if not ref:
|
|
122
|
+
raise ValueError("Empty run/task reference.")
|
|
123
|
+
|
|
124
|
+
manifest = _load_manifest(project_root, ref)
|
|
125
|
+
if manifest:
|
|
126
|
+
return ref, str(manifest.get("task_id") or "")
|
|
127
|
+
|
|
128
|
+
# Treat as a task id: newest manifest that claims it.
|
|
129
|
+
runs_dir = _runs_dir(project_root)
|
|
130
|
+
candidates: list[tuple[float, str]] = []
|
|
131
|
+
if runs_dir.is_dir():
|
|
132
|
+
for manifest_path in runs_dir.glob("*/agent-run.json"):
|
|
133
|
+
data = _load_manifest(project_root, manifest_path.parent.name)
|
|
134
|
+
if str(data.get("task_id") or "") == ref:
|
|
135
|
+
try:
|
|
136
|
+
mtime = manifest_path.stat().st_mtime
|
|
137
|
+
except OSError:
|
|
138
|
+
mtime = 0.0
|
|
139
|
+
candidates.append((mtime, manifest_path.parent.name))
|
|
140
|
+
if candidates:
|
|
141
|
+
candidates.sort(reverse=True)
|
|
142
|
+
return candidates[0][1], ref
|
|
143
|
+
|
|
144
|
+
# No manifest at all — accept a bare task id if it has checkpoints, so runs made
|
|
145
|
+
# before manifests existed (or by native executors) are still addressable.
|
|
146
|
+
service = CheckpointService(project_root)
|
|
147
|
+
if service._ref_exists(CheckpointService.REF_BEFORE.format(task_id=ref)) or (
|
|
148
|
+
service.checkpoint_dir / f"{ref}-after.patch"
|
|
149
|
+
).exists():
|
|
150
|
+
return "", ref
|
|
151
|
+
raise ValueError(f"No run manifest or checkpoints found for {ref!r}.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- Timeline assembly --------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _git_lines(project_root: Path, *args: str) -> str:
|
|
158
|
+
from devcouncil.utils.proc import git_output
|
|
159
|
+
|
|
160
|
+
return git_output(list(args), cwd=project_root, default="").strip()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _collect_checkpoints(project_root: Path, task_id: str) -> list[RunCheckpoint]:
|
|
164
|
+
if not task_id:
|
|
165
|
+
return []
|
|
166
|
+
service = CheckpointService(project_root)
|
|
167
|
+
checkpoints: list[RunCheckpoint] = []
|
|
168
|
+
|
|
169
|
+
def add(stage: str, ref: str, patch_name: str = "") -> None:
|
|
170
|
+
sha = _git_lines(project_root, "rev-parse", "--verify", ref)
|
|
171
|
+
patch = service.checkpoint_dir / patch_name if patch_name else None
|
|
172
|
+
if sha or (patch is not None and patch.exists()):
|
|
173
|
+
checkpoints.append(RunCheckpoint(
|
|
174
|
+
stage=stage,
|
|
175
|
+
ref=ref,
|
|
176
|
+
sha=sha,
|
|
177
|
+
patch_path=str(patch) if patch is not None and patch.exists() else "",
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
add("before", CheckpointService.REF_BEFORE.format(task_id=task_id), f"{task_id}-before.patch")
|
|
181
|
+
add("after", CheckpointService.REF_AFTER.format(task_id=task_id), f"{task_id}-after.patch")
|
|
182
|
+
|
|
183
|
+
# Attempt refs live under refs/devcouncil/tasks/<task>/attempts/<n>.
|
|
184
|
+
prefix = f"refs/devcouncil/tasks/{task_id}/attempts/"
|
|
185
|
+
listing = _git_lines(project_root, "for-each-ref", "--format=%(refname) %(objectname)", prefix)
|
|
186
|
+
for line in listing.splitlines():
|
|
187
|
+
parts = line.split()
|
|
188
|
+
if len(parts) == 2 and parts[0].startswith(prefix):
|
|
189
|
+
checkpoints.append(RunCheckpoint(
|
|
190
|
+
stage=f"attempt-{parts[0][len(prefix):]}", ref=parts[0], sha=parts[1]
|
|
191
|
+
))
|
|
192
|
+
return checkpoints
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def diff_run(project_root: Path, task_id: str, *, stat_only: bool = False) -> str:
|
|
196
|
+
"""The workspace changes a run produced: ``git diff before..after`` when both refs
|
|
197
|
+
exist, falling back to the recorded after-patch."""
|
|
198
|
+
if not task_id:
|
|
199
|
+
return ""
|
|
200
|
+
before = CheckpointService.REF_BEFORE.format(task_id=task_id)
|
|
201
|
+
after = CheckpointService.REF_AFTER.format(task_id=task_id)
|
|
202
|
+
if _git_lines(project_root, "rev-parse", "--verify", before) and _git_lines(
|
|
203
|
+
project_root, "rev-parse", "--verify", after
|
|
204
|
+
):
|
|
205
|
+
args = ["diff", "--stat", before, after] if stat_only else ["diff", before, after]
|
|
206
|
+
return _git_lines(project_root, *args)
|
|
207
|
+
patch = project_root / ".devcouncil" / "checkpoints" / f"{task_id}-after.patch"
|
|
208
|
+
try:
|
|
209
|
+
content = patch.read_text(encoding="utf-8")
|
|
210
|
+
except OSError:
|
|
211
|
+
return ""
|
|
212
|
+
if not stat_only:
|
|
213
|
+
return content
|
|
214
|
+
# Cheap stat for a raw patch: count per-file hunks.
|
|
215
|
+
files = [line.split(" b/", 1)[-1] for line in content.splitlines() if line.startswith("diff --git ")]
|
|
216
|
+
return "\n".join(files)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def load_timeline(project_root: Path, ref: str, *, event_limit: int = 200) -> RunTimeline:
|
|
220
|
+
"""Assemble the full reversible-trace view for a run (or bare task) reference."""
|
|
221
|
+
run_id, task_id = resolve_run(project_root, ref)
|
|
222
|
+
manifest = _load_manifest(project_root, run_id) if run_id else {}
|
|
223
|
+
if not task_id:
|
|
224
|
+
task_id = str(manifest.get("task_id") or "")
|
|
225
|
+
|
|
226
|
+
events = [
|
|
227
|
+
e for e in read_trace_events(project_root)
|
|
228
|
+
if (run_id and e.run_id == run_id) or (task_id and e.task_id == task_id)
|
|
229
|
+
]
|
|
230
|
+
if event_limit > 0:
|
|
231
|
+
events = events[-event_limit:]
|
|
232
|
+
|
|
233
|
+
checkpoints = _collect_checkpoints(project_root, task_id)
|
|
234
|
+
reversible = any(c.stage in ("before", "after") for c in checkpoints)
|
|
235
|
+
return RunTimeline(
|
|
236
|
+
run_id=run_id,
|
|
237
|
+
task_id=task_id,
|
|
238
|
+
manifest=manifest,
|
|
239
|
+
events=events,
|
|
240
|
+
checkpoints=checkpoints,
|
|
241
|
+
diff_stat=diff_run(project_root, task_id, stat_only=True),
|
|
242
|
+
reversible=reversible,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --- Meta-agent operations -----------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def revert_run(project_root: Path, ref: str) -> CheckpointResult:
|
|
250
|
+
"""Reverse a run's workspace effects using its task's git checkpoints.
|
|
251
|
+
|
|
252
|
+
The revert itself is recorded as a trace event, so the trace stays a faithful,
|
|
253
|
+
append-only history of everything that happened — including supervision.
|
|
254
|
+
"""
|
|
255
|
+
run_id, task_id = resolve_run(project_root, ref)
|
|
256
|
+
if not task_id:
|
|
257
|
+
raise ValueError(f"Run {ref!r} has no task id; nothing to revert.")
|
|
258
|
+
result = CheckpointService(project_root).rollback(task_id)
|
|
259
|
+
reverted = result.git_ref_created or bool(result.patch_path and "Rolled back" in result.message)
|
|
260
|
+
TraceLogger(project_root).log_event(
|
|
261
|
+
"run_reverted" if reverted else "run_revert_failed",
|
|
262
|
+
{"reference": ref, "message": result.message},
|
|
263
|
+
run_id=run_id or None,
|
|
264
|
+
task_id=task_id,
|
|
265
|
+
summary=f"Supervisor revert of {task_id}: {result.message}",
|
|
266
|
+
)
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def heuristic_verdict(timeline: RunTimeline) -> SupervisorVerdict:
|
|
271
|
+
"""Deterministic verdict from the recorded evidence (no model required)."""
|
|
272
|
+
findings: list[str] = []
|
|
273
|
+
rc = timeline.returncode
|
|
274
|
+
if rc not in (None, 0):
|
|
275
|
+
findings.append(f"Executor exited with returncode {rc}.")
|
|
276
|
+
if timeline.status == "running":
|
|
277
|
+
findings.append("Run is still marked 'running' (possibly orphaned).")
|
|
278
|
+
failures = [e for e in timeline.events if e.type in _FAILURE_EVENT_TYPES]
|
|
279
|
+
for event in failures[:5]:
|
|
280
|
+
findings.append(f"Failure event: {event.type} — {event.summary or '(no summary)'}")
|
|
281
|
+
if not timeline.diff_stat:
|
|
282
|
+
findings.append("No workspace diff recorded for this run.")
|
|
283
|
+
|
|
284
|
+
if rc not in (None, 0) and timeline.diff_stat:
|
|
285
|
+
verdict: Literal["keep", "revert", "repair"] = "revert"
|
|
286
|
+
rationale = "The executor failed after modifying the workspace; its changes are unverified."
|
|
287
|
+
elif failures:
|
|
288
|
+
verdict = "repair"
|
|
289
|
+
rationale = "The run completed but recorded failure events; keep the changes and fix forward."
|
|
290
|
+
else:
|
|
291
|
+
verdict = "keep"
|
|
292
|
+
rationale = "No failure evidence recorded for this run."
|
|
293
|
+
return SupervisorVerdict(
|
|
294
|
+
verdict=verdict,
|
|
295
|
+
confidence=0.4 if findings else 0.6,
|
|
296
|
+
rationale=rationale,
|
|
297
|
+
findings=findings,
|
|
298
|
+
source="heuristic",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def supervise_run(
|
|
303
|
+
project_root: Path,
|
|
304
|
+
timeline: RunTimeline,
|
|
305
|
+
router: "Optional[ModelRouter]" = None,
|
|
306
|
+
*,
|
|
307
|
+
diff_chars: int = 12_000,
|
|
308
|
+
) -> SupervisorVerdict:
|
|
309
|
+
"""Review a run and produce a keep/revert/repair verdict.
|
|
310
|
+
|
|
311
|
+
With a router, the ``run_supervisor`` role reviews the manifest, a bounded event
|
|
312
|
+
timeline, and the run's diff; the deterministic heuristic verdict is both the
|
|
313
|
+
degradation fallback and part of the prompt (so the model starts from the
|
|
314
|
+
recorded evidence rather than re-deriving it).
|
|
315
|
+
"""
|
|
316
|
+
fallback = heuristic_verdict(timeline)
|
|
317
|
+
if router is None:
|
|
318
|
+
return fallback
|
|
319
|
+
|
|
320
|
+
events_payload = [
|
|
321
|
+
{"type": e.type, "summary": e.summary, "timestamp": e.timestamp}
|
|
322
|
+
for e in timeline.events[-60:]
|
|
323
|
+
]
|
|
324
|
+
diff = diff_run(project_root, timeline.task_id)[:diff_chars]
|
|
325
|
+
payload = {
|
|
326
|
+
"manifest": timeline.manifest,
|
|
327
|
+
"heuristic_findings": fallback.findings,
|
|
328
|
+
"events": events_payload,
|
|
329
|
+
"diff_stat": timeline.diff_stat,
|
|
330
|
+
"diff_excerpt": diff,
|
|
331
|
+
"reversible": timeline.reversible,
|
|
332
|
+
}
|
|
333
|
+
messages = [
|
|
334
|
+
{"role": "system", "content": _SUPERVISOR_SYSTEM},
|
|
335
|
+
{"role": "user", "content": f"Run evidence (JSON):\n{json.dumps(payload, indent=2, default=str)}"},
|
|
336
|
+
]
|
|
337
|
+
try:
|
|
338
|
+
verdict = await router.complete_structured(
|
|
339
|
+
"run_supervisor", messages, SupervisorVerdict, fallback=fallback,
|
|
340
|
+
run_id=timeline.run_id or None,
|
|
341
|
+
)
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
# A router without a run_supervisor role (or any other model failure) must
|
|
344
|
+
# degrade to the heuristic verdict, never break supervision.
|
|
345
|
+
logger.warning("Model supervision unavailable; using heuristic verdict: %s", exc)
|
|
346
|
+
verdict = fallback
|
|
347
|
+
if verdict is not fallback:
|
|
348
|
+
verdict.source = "model"
|
|
349
|
+
TraceLogger(project_root).log_event(
|
|
350
|
+
"run_supervised",
|
|
351
|
+
{"verdict": verdict.verdict, "confidence": verdict.confidence, "source": verdict.source},
|
|
352
|
+
run_id=timeline.run_id or None,
|
|
353
|
+
task_id=timeline.task_id or None,
|
|
354
|
+
summary=f"Supervisor verdict for {timeline.task_id or timeline.run_id}: {verdict.verdict}",
|
|
355
|
+
)
|
|
356
|
+
return verdict
|
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
from rich.console import Console
|
|
14
14
|
|
|
15
|
+
from devcouncil.app.config import load_config
|
|
15
16
|
from devcouncil.domain.evidence import CommandResult
|
|
16
17
|
from devcouncil.domain.task import Task
|
|
17
18
|
from devcouncil.execution.checkpoints import CheckpointService
|
|
@@ -25,12 +26,26 @@ logger = logging.getLogger(__name__)
|
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
class ShellBackend:
|
|
28
|
-
def run_command(
|
|
29
|
+
def run_command(
|
|
30
|
+
self,
|
|
31
|
+
command: str,
|
|
32
|
+
cwd: Path,
|
|
33
|
+
env: dict[str, str] | None = None,
|
|
34
|
+
*,
|
|
35
|
+
timeout: int | None = None,
|
|
36
|
+
) -> subprocess.CompletedProcess[str]:
|
|
29
37
|
raise NotImplementedError
|
|
30
38
|
|
|
31
39
|
|
|
32
40
|
class CommandLoopBackend(ShellBackend):
|
|
33
|
-
def run_command(
|
|
41
|
+
def run_command(
|
|
42
|
+
self,
|
|
43
|
+
command: str,
|
|
44
|
+
cwd: Path,
|
|
45
|
+
env: dict[str, str] | None = None,
|
|
46
|
+
*,
|
|
47
|
+
timeout: int | None = None,
|
|
48
|
+
) -> subprocess.CompletedProcess[str]:
|
|
34
49
|
args = shlex.split(command, posix=(os.name != "nt"))
|
|
35
50
|
return subprocess.run(
|
|
36
51
|
args,
|
|
@@ -40,6 +55,7 @@ class CommandLoopBackend(ShellBackend):
|
|
|
40
55
|
encoding="utf-8",
|
|
41
56
|
errors="replace",
|
|
42
57
|
env=env,
|
|
58
|
+
timeout=timeout,
|
|
43
59
|
)
|
|
44
60
|
|
|
45
61
|
|
|
@@ -63,7 +79,14 @@ class ShellWrappedBackend(ShellBackend):
|
|
|
63
79
|
raise ValueError(f"Shell '{launcher[0]}' is not installed or not on PATH.")
|
|
64
80
|
self.launcher = launcher
|
|
65
81
|
|
|
66
|
-
def run_command(
|
|
82
|
+
def run_command(
|
|
83
|
+
self,
|
|
84
|
+
command: str,
|
|
85
|
+
cwd: Path,
|
|
86
|
+
env: dict[str, str] | None = None,
|
|
87
|
+
*,
|
|
88
|
+
timeout: int | None = None,
|
|
89
|
+
) -> subprocess.CompletedProcess[str]:
|
|
67
90
|
return subprocess.run(
|
|
68
91
|
[*self.launcher, command],
|
|
69
92
|
cwd=cwd,
|
|
@@ -72,6 +95,7 @@ class ShellWrappedBackend(ShellBackend):
|
|
|
72
95
|
encoding="utf-8",
|
|
73
96
|
errors="replace",
|
|
74
97
|
env=env,
|
|
98
|
+
timeout=timeout,
|
|
75
99
|
)
|
|
76
100
|
|
|
77
101
|
|
|
@@ -81,10 +105,16 @@ console = Console()
|
|
|
81
105
|
|
|
82
106
|
|
|
83
107
|
class GuardedShellSession:
|
|
84
|
-
def __init__(self, project_root: Path, task: Task, *, shell: str = "auto"):
|
|
108
|
+
def __init__(self, project_root: Path, task: Task, *, shell: str = "auto", command_timeout: int | None = None):
|
|
85
109
|
self.project_root = project_root.resolve()
|
|
86
110
|
self.task = task
|
|
87
111
|
self.shell = shell
|
|
112
|
+
if command_timeout is None:
|
|
113
|
+
try:
|
|
114
|
+
command_timeout = load_config(self.project_root).execution.command_timeout
|
|
115
|
+
except Exception:
|
|
116
|
+
command_timeout = 300
|
|
117
|
+
self.command_timeout = max(1, int(command_timeout))
|
|
88
118
|
# HookPolicy (not the bare TaskPolicyEngine) so a chained command is split into
|
|
89
119
|
# its segments and EACH is allowlisted, plus git-safety denies (force-push,
|
|
90
120
|
# --no-verify, protected-branch reset). Critical for the `--shell bash/zsh`
|
|
@@ -158,7 +188,18 @@ class GuardedShellSession:
|
|
|
158
188
|
|
|
159
189
|
logger.info("Shell command for %s: %s", self.task.id, normalized)
|
|
160
190
|
try:
|
|
161
|
-
result = self.backend.run_command(
|
|
191
|
+
result = self.backend.run_command(
|
|
192
|
+
normalized, self.project_root, timeout=self.command_timeout,
|
|
193
|
+
)
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
reason = f"Command timed out after {self.command_timeout}s"
|
|
196
|
+
logger.warning("Shell command timed out for %s: %s", self.task.id, normalized)
|
|
197
|
+
self._record_command(normalized, "failed", reason=reason)
|
|
198
|
+
console.print(f"[red]Command timed out after {self.command_timeout}s:[/red] {normalized}")
|
|
199
|
+
console.print(
|
|
200
|
+
"[dim]Increase execution.command_timeout in .devcouncil/config.yaml or split the command.[/dim]"
|
|
201
|
+
)
|
|
202
|
+
return 1
|
|
162
203
|
except (NotImplementedError, FileNotFoundError, OSError) as exc:
|
|
163
204
|
logger.warning("Shell command could not run for %s: %s (%s)", self.task.id, normalized, exc)
|
|
164
205
|
self._record_command(normalized, "denied", reason=str(exc))
|