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
|
@@ -10,16 +10,26 @@ import time
|
|
|
10
10
|
import uuid
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Optional
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
14
|
|
|
15
15
|
from rich.console import Console
|
|
16
16
|
|
|
17
17
|
from devcouncil.domain.requirement import Requirement
|
|
18
18
|
from devcouncil.domain.task import Task
|
|
19
19
|
from devcouncil.app.config import DevCouncilConfig, load_config
|
|
20
|
+
from devcouncil.utils.json_persist import read_json, write_json
|
|
20
21
|
from devcouncil.execution.executor import Executor, ExecutionResult
|
|
21
22
|
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
23
|
+
from devcouncil.executors.advisor_tool import (
|
|
24
|
+
advisor_steering_text,
|
|
25
|
+
advisor_user_cost_trim,
|
|
26
|
+
claude_supports_append_system_prompt,
|
|
27
|
+
decide_advisor_attach,
|
|
28
|
+
strip_duplicate_advisor_args,
|
|
29
|
+
warn_advisor_preflight,
|
|
30
|
+
)
|
|
22
31
|
from devcouncil.executors.agent_registry import (
|
|
32
|
+
GEMINI_DEPRECATION_MESSAGE,
|
|
23
33
|
VALID_INPUT_MODES,
|
|
24
34
|
CliAgentSpec,
|
|
25
35
|
get_cli_agent_spec,
|
|
@@ -29,6 +39,7 @@ from devcouncil.executors.agent_registry import (
|
|
|
29
39
|
)
|
|
30
40
|
from devcouncil.repo.gitignore import ensure_gitignore
|
|
31
41
|
from devcouncil.telemetry.traces import TraceLogger
|
|
42
|
+
from devcouncil.telemetry.stages import log_step
|
|
32
43
|
from devcouncil.telemetry.logging_setup import run_log
|
|
33
44
|
from devcouncil.utils.redaction import redact_text
|
|
34
45
|
|
|
@@ -64,11 +75,21 @@ class CodingCliExecutor(Executor):
|
|
|
64
75
|
self._config = None
|
|
65
76
|
self.client = self._normalize_client(client)
|
|
66
77
|
self.timeout_seconds = timeout_seconds
|
|
78
|
+
if self.client == "gemini":
|
|
79
|
+
logger.warning(GEMINI_DEPRECATION_MESSAGE)
|
|
80
|
+
console.print(f"[yellow]{GEMINI_DEPRECATION_MESSAGE}[/yellow]")
|
|
67
81
|
self.spec = self._resolve_spec()
|
|
68
82
|
self.profile_name = profile or self.spec.default_profile or "default"
|
|
69
83
|
self.profile = load_agent_profiles(project_root).get(self.profile_name)
|
|
70
84
|
self.last_run_id: str | None = None
|
|
71
85
|
self.last_transcript_path: Path | None = None
|
|
86
|
+
# Claude Code session id for the most recent run (assigned by DevCouncil via
|
|
87
|
+
# --session-id, or reused via --resume). Lets callers/live-review locate the
|
|
88
|
+
# native ~/.claude transcript for a headless run and resume it for repairs.
|
|
89
|
+
self.last_agent_session_id: str | None = None
|
|
90
|
+
self._pending_claude_session: tuple[Path, str] | None = None
|
|
91
|
+
# True when this run's argv includes ``--advisor`` (Claude only).
|
|
92
|
+
self._advisor_attached: bool = False
|
|
72
93
|
self.stream_output = self._resolve_stream_output(stream_output)
|
|
73
94
|
|
|
74
95
|
def _normalize_client(self, client: str) -> str:
|
|
@@ -95,6 +116,10 @@ class CodingCliExecutor(Executor):
|
|
|
95
116
|
base = self._warp_command()
|
|
96
117
|
elif self.client == "cursor":
|
|
97
118
|
base = self._cursor_command(task_id)
|
|
119
|
+
elif self.client == "claude":
|
|
120
|
+
base = self._claude_command(task_id)
|
|
121
|
+
elif self.client == "grok":
|
|
122
|
+
base = self._grok_command(task_id)
|
|
98
123
|
else:
|
|
99
124
|
base = self.spec.base_command()
|
|
100
125
|
return self._apply_profile_args(base)
|
|
@@ -106,6 +131,7 @@ class CodingCliExecutor(Executor):
|
|
|
106
131
|
"codex": "--model",
|
|
107
132
|
"gemini": "--model",
|
|
108
133
|
"cursor": "--model",
|
|
134
|
+
"grok": "-m",
|
|
109
135
|
"qwen": "--model",
|
|
110
136
|
"opencode": "--model",
|
|
111
137
|
"aider": "--model",
|
|
@@ -125,12 +151,57 @@ class CodingCliExecutor(Executor):
|
|
|
125
151
|
result = list(command)
|
|
126
152
|
result = self._apply_permission_mode(result)
|
|
127
153
|
result = self._apply_model_override(result)
|
|
154
|
+
result = self._apply_advisor(result)
|
|
128
155
|
# NOTE: extra_args are NOT appended here. For argument/prompt-file CLIs the prompt
|
|
129
156
|
# (and sometimes its flag, e.g. warp --prompt / aider --message) is appended last
|
|
130
157
|
# by _invocation; appending extra_args at the tail here would slot them between the
|
|
131
158
|
# prompt flag and its value. _invocation places them correctly instead.
|
|
132
159
|
return result
|
|
133
160
|
|
|
161
|
+
def _apply_advisor(self, command: list[str]) -> list[str]:
|
|
162
|
+
"""Attach ``--advisor`` for Claude when the profile opts in and pairing looks safe.
|
|
163
|
+
|
|
164
|
+
Soft-skips clear bad main/advisor pairs and non-Anthropic provider envs so Claude
|
|
165
|
+
does not hard-exit and burn the go-loop repair budget. Non-Claude clients ignore
|
|
166
|
+
``advisor_model``.
|
|
167
|
+
"""
|
|
168
|
+
self._advisor_attached = False
|
|
169
|
+
if self.client != "claude" or not self.profile:
|
|
170
|
+
return command
|
|
171
|
+
advisor = (self.profile.advisor_model or "").strip()
|
|
172
|
+
if not advisor:
|
|
173
|
+
return command
|
|
174
|
+
# Prefer profile env overrides, then process env, for provider soft-skip.
|
|
175
|
+
env_for_decision: dict[str, str] = {**os.environ}
|
|
176
|
+
if self.profile.env:
|
|
177
|
+
env_for_decision.update({str(k): str(v) for k, v in self.profile.env.items()})
|
|
178
|
+
decision, resolved, reason = decide_advisor_attach(
|
|
179
|
+
main_model=self.profile.model,
|
|
180
|
+
advisor_model=advisor,
|
|
181
|
+
env=env_for_decision,
|
|
182
|
+
)
|
|
183
|
+
if decision != "attach" or not resolved:
|
|
184
|
+
if reason:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"Skipping --advisor %s for profile %s: %s",
|
|
187
|
+
advisor,
|
|
188
|
+
self.profile_name,
|
|
189
|
+
reason,
|
|
190
|
+
)
|
|
191
|
+
console.print(
|
|
192
|
+
f"[yellow]Skipping --advisor {advisor}: {reason}. "
|
|
193
|
+
"Run continues without the advisor tool.[/yellow]"
|
|
194
|
+
)
|
|
195
|
+
return command
|
|
196
|
+
result = list(command)
|
|
197
|
+
for index, part in enumerate(result):
|
|
198
|
+
if part == "--advisor" and index + 1 < len(result):
|
|
199
|
+
result[index + 1] = resolved
|
|
200
|
+
self._advisor_attached = True
|
|
201
|
+
return result
|
|
202
|
+
self._advisor_attached = True
|
|
203
|
+
return [*result, "--advisor", resolved]
|
|
204
|
+
|
|
134
205
|
def _apply_model_override(self, command: list[str]) -> list[str]:
|
|
135
206
|
model = (self.profile.model or "").strip() if self.profile else ""
|
|
136
207
|
if not model:
|
|
@@ -147,10 +218,27 @@ class CodingCliExecutor(Executor):
|
|
|
147
218
|
|
|
148
219
|
def _apply_permission_mode(self, command: list[str]) -> list[str]:
|
|
149
220
|
mode = (self.profile.permission_mode or "").strip() if self.profile else ""
|
|
221
|
+
if not mode and self.client == "cursor" and self._config is not None:
|
|
222
|
+
headless_force = self._config.integrations.cursor.headless_force
|
|
223
|
+
if headless_force is True:
|
|
224
|
+
return self._apply_cursor_permission_mode(command, "auto")
|
|
225
|
+
if headless_force is False:
|
|
226
|
+
return command
|
|
150
227
|
if not mode:
|
|
151
228
|
return command
|
|
229
|
+
if mode.lower() == "bypasspermissions":
|
|
230
|
+
logger.warning(
|
|
231
|
+
"Profile %r uses bypassPermissions — file edits bypass all CLI gates.",
|
|
232
|
+
self.profile_name,
|
|
233
|
+
)
|
|
152
234
|
if self.client == "claude":
|
|
153
235
|
return self._apply_claude_permission_mode(command, mode)
|
|
236
|
+
if self.client == "codex":
|
|
237
|
+
return self._apply_codex_permission_mode(command, mode)
|
|
238
|
+
if self.client == "grok":
|
|
239
|
+
return self._apply_grok_permission_mode(command, mode)
|
|
240
|
+
if self.client == "cursor":
|
|
241
|
+
return self._apply_cursor_permission_mode(command, mode)
|
|
154
242
|
return command
|
|
155
243
|
|
|
156
244
|
@staticmethod
|
|
@@ -174,6 +262,73 @@ class CodingCliExecutor(Executor):
|
|
|
174
262
|
return result
|
|
175
263
|
return [*result, "--permission-mode", value]
|
|
176
264
|
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _apply_codex_permission_mode(command: list[str], mode: str) -> list[str]:
|
|
267
|
+
"""Translate DevCouncil profiles into current ``codex exec`` flags.
|
|
268
|
+
|
|
269
|
+
``auto`` remains sandboxed to the workspace, ``plan`` is read-only, and
|
|
270
|
+
only an explicit bypass mode disables both approvals and the sandbox.
|
|
271
|
+
"""
|
|
272
|
+
normalized = mode.lower()
|
|
273
|
+
result = list(command)
|
|
274
|
+
dangerous = "--dangerously-bypass-approvals-and-sandbox"
|
|
275
|
+
if normalized in {"bypasspermissions", "danger-full-access", "yolo"}:
|
|
276
|
+
if dangerous not in result:
|
|
277
|
+
result.append(dangerous)
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
sandbox = {
|
|
281
|
+
"auto": "workspace-write",
|
|
282
|
+
"gated": "workspace-write",
|
|
283
|
+
"ask": "workspace-write",
|
|
284
|
+
"prod": "workspace-write",
|
|
285
|
+
"plan": "read-only",
|
|
286
|
+
"read-only": "read-only",
|
|
287
|
+
"workspace-write": "workspace-write",
|
|
288
|
+
}.get(normalized)
|
|
289
|
+
if sandbox is None:
|
|
290
|
+
return result
|
|
291
|
+
for index, part in enumerate(result):
|
|
292
|
+
if part in {"--sandbox", "-s"} and index + 1 < len(result):
|
|
293
|
+
result[index + 1] = sandbox
|
|
294
|
+
return result
|
|
295
|
+
return [*result, "--sandbox", sandbox]
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _apply_grok_permission_mode(command: list[str], mode: str) -> list[str]:
|
|
299
|
+
"""Translate an abstract permission mode into Grok Build's
|
|
300
|
+
``--permission-mode`` value."""
|
|
301
|
+
translation = {
|
|
302
|
+
"auto": "acceptEdits",
|
|
303
|
+
"yolo": "acceptEdits",
|
|
304
|
+
"gated": "dontAsk",
|
|
305
|
+
"prod": "dontAsk",
|
|
306
|
+
"ask": "default",
|
|
307
|
+
"plan": "plan",
|
|
308
|
+
}
|
|
309
|
+
value = translation.get(mode.lower(), mode)
|
|
310
|
+
result = list(command)
|
|
311
|
+
for index, part in enumerate(result):
|
|
312
|
+
if part == "--permission-mode" and index + 1 < len(result):
|
|
313
|
+
result[index + 1] = value
|
|
314
|
+
return result
|
|
315
|
+
return [*result, "--permission-mode", value]
|
|
316
|
+
|
|
317
|
+
def _apply_cursor_permission_mode(self, command: list[str], mode: str) -> list[str]:
|
|
318
|
+
"""Map profile permission modes to Cursor headless flags."""
|
|
319
|
+
normalized = mode.lower()
|
|
320
|
+
result = list(command)
|
|
321
|
+
if normalized in {"auto", "yolo"}:
|
|
322
|
+
if "--force" not in result and "--yolo" not in result:
|
|
323
|
+
result.insert(1, "--force")
|
|
324
|
+
return result
|
|
325
|
+
if normalized == "plan":
|
|
326
|
+
if "--mode=plan" not in result:
|
|
327
|
+
result.insert(1, "--mode=plan")
|
|
328
|
+
return result
|
|
329
|
+
# gated/prod: omit force; trust + verify loop contain edits.
|
|
330
|
+
return result
|
|
331
|
+
|
|
177
332
|
def _cursor_command(self, task_id: str | None = None) -> list[str]:
|
|
178
333
|
executable = resolve_cursor_agent_executable()
|
|
179
334
|
if not executable:
|
|
@@ -185,12 +340,33 @@ class CodingCliExecutor(Executor):
|
|
|
185
340
|
"--workspace",
|
|
186
341
|
str(self.project_root),
|
|
187
342
|
]
|
|
343
|
+
if self.stream_output:
|
|
344
|
+
command.extend(["--output-format", "stream-json", "--stream-partial-output"])
|
|
345
|
+
else:
|
|
346
|
+
command.extend(["--output-format", "json"])
|
|
188
347
|
chat_id = self._cursor_resume_chat_id(task_id)
|
|
189
348
|
if chat_id:
|
|
190
349
|
command.extend(["--resume", chat_id])
|
|
191
350
|
command.append("Read and execute the DevCouncil task prompt at {prompt_file}.")
|
|
192
351
|
return command
|
|
193
352
|
|
|
353
|
+
def _grok_command(self, task_id: str | None = None) -> list[str]:
|
|
354
|
+
command = [
|
|
355
|
+
"grok",
|
|
356
|
+
"-p",
|
|
357
|
+
"Read and execute the DevCouncil task prompt at {prompt_file}.",
|
|
358
|
+
"--directory",
|
|
359
|
+
str(self.project_root),
|
|
360
|
+
]
|
|
361
|
+
if self.stream_output:
|
|
362
|
+
command.extend(["--output-format", "stream-json"])
|
|
363
|
+
else:
|
|
364
|
+
command.extend(["--output-format", "json"])
|
|
365
|
+
session_id = self._grok_resume_session_id(task_id)
|
|
366
|
+
if session_id:
|
|
367
|
+
command.extend(["--resume", session_id])
|
|
368
|
+
return command
|
|
369
|
+
|
|
194
370
|
def _warp_command(self) -> list[str]:
|
|
195
371
|
config = self._load_warp_config()
|
|
196
372
|
command = config.get("command", "oz")
|
|
@@ -233,6 +409,12 @@ class CodingCliExecutor(Executor):
|
|
|
233
409
|
|
|
234
410
|
def run_task(self, task: Task, requirements: list[Requirement]) -> ExecutionResult:
|
|
235
411
|
logger.info("coding_cli.run_task: client=%s profile=%s task=%s", self.client, self.profile_name, task.id)
|
|
412
|
+
log_step(
|
|
413
|
+
f"executor/{self.client}: starting task {task.id}",
|
|
414
|
+
project_root=self.project_root,
|
|
415
|
+
task_id=task.id,
|
|
416
|
+
profile=self.profile_name,
|
|
417
|
+
)
|
|
236
418
|
if self.profile is None:
|
|
237
419
|
logger.error("Unknown agent profile %r for %s; cannot start.", self.profile_name, self.client)
|
|
238
420
|
return ExecutionResult(
|
|
@@ -266,23 +448,27 @@ class CodingCliExecutor(Executor):
|
|
|
266
448
|
)
|
|
267
449
|
|
|
268
450
|
prompt = PromptBuilder(self.project_root).build_task_prompt(task, requirements)
|
|
269
|
-
from devcouncil.planning.correction_manifest import
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if
|
|
273
|
-
prompt =
|
|
274
|
-
f"# DevCouncil Correction Manifest\n\n"
|
|
275
|
-
f"{correction.model_dump_json(indent=2)}\n\n"
|
|
276
|
-
f"{prompt}"
|
|
277
|
-
)
|
|
451
|
+
from devcouncil.planning.correction_manifest import repair_prompt_prefix
|
|
452
|
+
|
|
453
|
+
prefix = repair_prompt_prefix(self.project_root, task.id)
|
|
454
|
+
if prefix:
|
|
455
|
+
prompt = f"{prefix}{prompt}"
|
|
278
456
|
prompt = self._apply_profile_prompt(prompt)
|
|
457
|
+
# Claude-only advisor steering (not PromptBuilder — shared by all executors).
|
|
458
|
+
prompt, advisor_system = self._apply_advisor_steering(prompt, repair=bool(prefix))
|
|
279
459
|
instruction_file = self.project_root / ".devcouncil" / f"{task.id}-{self.client}-task.md"
|
|
280
460
|
instruction_file.parent.mkdir(parents=True, exist_ok=True)
|
|
281
461
|
instruction_file.write_text(prompt, encoding="utf-8")
|
|
282
462
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
463
|
+
env = self._build_env()
|
|
464
|
+
if self.client == "claude" and self._advisor_attached:
|
|
465
|
+
for warning in warn_advisor_preflight(
|
|
466
|
+
env=env,
|
|
467
|
+
main_model=self.profile.model if self.profile else None,
|
|
468
|
+
advisor_model=self.profile.advisor_model if self.profile else None,
|
|
469
|
+
):
|
|
470
|
+
logger.warning("%s", warning)
|
|
471
|
+
console.print(f"[yellow]{warning}[/yellow]")
|
|
286
472
|
log_prefix = f"{task.id}-{self.client}"
|
|
287
473
|
run_id = str(uuid.uuid4())
|
|
288
474
|
self.last_run_id = run_id
|
|
@@ -299,6 +485,8 @@ class CodingCliExecutor(Executor):
|
|
|
299
485
|
started = time.monotonic()
|
|
300
486
|
try:
|
|
301
487
|
invocation, input_text = self._invocation(command, prompt, instruction_file)
|
|
488
|
+
if advisor_system:
|
|
489
|
+
invocation = self._inject_append_system_prompt(invocation, advisor_system, prompt)
|
|
302
490
|
display_invocation = self._display_invocation(invocation, prompt)
|
|
303
491
|
# Print the resolved command (placeholders like {prompt_file} already
|
|
304
492
|
# substituted, prompt redacted) rather than the raw template.
|
|
@@ -310,6 +498,12 @@ class CodingCliExecutor(Executor):
|
|
|
310
498
|
instruction_file,
|
|
311
499
|
stream=self.stream_output,
|
|
312
500
|
)
|
|
501
|
+
# Persist the Claude session id now (before the subprocess), so a crashed or
|
|
502
|
+
# timed-out run still leaves a resumable session, and record it on the manifest.
|
|
503
|
+
if self.client == "claude":
|
|
504
|
+
self._persist_claude_session()
|
|
505
|
+
if self.last_agent_session_id:
|
|
506
|
+
self._update_run_manifest(run_id, agent_session_id=self.last_agent_session_id)
|
|
313
507
|
TraceLogger(self.project_root).log_event(
|
|
314
508
|
"agent_run_started",
|
|
315
509
|
{
|
|
@@ -330,11 +524,72 @@ class CodingCliExecutor(Executor):
|
|
|
330
524
|
)
|
|
331
525
|
started = time.monotonic()
|
|
332
526
|
logger.info("Launching %s subprocess for %s (timeout=%ss)", self.client, task.id, self._effective_timeout())
|
|
333
|
-
|
|
527
|
+
# Render Claude's NDJSON stream as readable lines while it runs (raw is still
|
|
528
|
+
# captured to the transcript); other clients print their output verbatim.
|
|
529
|
+
display_transform = (
|
|
530
|
+
self._render_claude_stream_event
|
|
531
|
+
if (self.client == "claude" and self.stream_output)
|
|
532
|
+
else None
|
|
533
|
+
)
|
|
534
|
+
result = self._run_subprocess(
|
|
535
|
+
invocation, input_text, env,
|
|
536
|
+
transcript_path=transcript_path,
|
|
537
|
+
display_transform=display_transform,
|
|
538
|
+
)
|
|
539
|
+
# Transient-failure retry: a CLI that died on a network/provider fault
|
|
540
|
+
# ("API Error: Connection closed mid-response", 429/5xx, overloaded) says
|
|
541
|
+
# nothing about the task — without a retry the failure ends the task
|
|
542
|
+
# `blocked`, burns a repair attempt on a non-code problem, and surfaces
|
|
543
|
+
# as a false negative (seen directly in the benchmark). Only failures
|
|
544
|
+
# whose output matches a known-transient signature are retried, with a
|
|
545
|
+
# short backoff; genuine agent errors still fail immediately.
|
|
546
|
+
retries = 0
|
|
547
|
+
retry_limit = self._transient_retry_limit()
|
|
548
|
+
while result.returncode != 0 and retries < retry_limit:
|
|
549
|
+
reason = self._transient_failure_reason(result)
|
|
550
|
+
if reason is None:
|
|
551
|
+
break
|
|
552
|
+
retries += 1
|
|
553
|
+
delay = min(30.0, 5.0 * retries)
|
|
554
|
+
logger.warning(
|
|
555
|
+
"%s failed with a transient error for %s (%s); retrying %d/%d in %.0fs",
|
|
556
|
+
self.client, task.id, reason, retries, retry_limit, delay,
|
|
557
|
+
)
|
|
558
|
+
TraceLogger(self.project_root).log_event(
|
|
559
|
+
"agent_run_transient_retry",
|
|
560
|
+
{
|
|
561
|
+
"agent": self.client,
|
|
562
|
+
"profile": self.profile_name,
|
|
563
|
+
"returncode": result.returncode,
|
|
564
|
+
"reason": reason,
|
|
565
|
+
"attempt": retries,
|
|
566
|
+
"limit": retry_limit,
|
|
567
|
+
},
|
|
568
|
+
run_id=run_id,
|
|
569
|
+
task_id=task.id,
|
|
570
|
+
summary=f"Transient {self.client} failure ({reason}); retry {retries}/{retry_limit}",
|
|
571
|
+
)
|
|
572
|
+
time.sleep(delay)
|
|
573
|
+
result = self._run_subprocess(
|
|
574
|
+
invocation, input_text, env,
|
|
575
|
+
transcript_path=transcript_path,
|
|
576
|
+
display_transform=display_transform,
|
|
577
|
+
)
|
|
334
578
|
duration = round(time.monotonic() - started, 3)
|
|
335
579
|
logger.info("%s subprocess for %s exited %s in %.2fs", self.client, task.id, result.returncode, duration)
|
|
336
580
|
finished_at = datetime.now(timezone.utc).isoformat()
|
|
337
581
|
self._write_log(log_prefix, result)
|
|
582
|
+
# Capture Claude's structured result onto the manifest. Non-stream: parse the one
|
|
583
|
+
# JSON blob and swap stdout for the human ``result`` text so previews stay readable.
|
|
584
|
+
# Stream: harvest the terminal result event from the captured NDJSON for telemetry.
|
|
585
|
+
if self.client == "claude":
|
|
586
|
+
if self.stream_output:
|
|
587
|
+
self._capture_claude_stream_json(run_id, result)
|
|
588
|
+
else:
|
|
589
|
+
result = self._capture_claude_json(run_id, result)
|
|
590
|
+
self._mirror_claude_transcript()
|
|
591
|
+
if self.client == "grok":
|
|
592
|
+
self._capture_grok_session_from_result(task.id, result)
|
|
338
593
|
if transcript_path and transcript_path.exists():
|
|
339
594
|
self._append_manifest_transcript(run_id, transcript_path)
|
|
340
595
|
self.last_transcript_path = transcript_path
|
|
@@ -379,6 +634,14 @@ class CodingCliExecutor(Executor):
|
|
|
379
634
|
task_id=task.id,
|
|
380
635
|
summary=f"{self.client} finished for {task.id}",
|
|
381
636
|
)
|
|
637
|
+
log_step(
|
|
638
|
+
f"executor/{self.client}: finished task {task.id}",
|
|
639
|
+
project_root=self.project_root,
|
|
640
|
+
task_id=task.id,
|
|
641
|
+
returncode=result.returncode,
|
|
642
|
+
duration_s=round(duration, 2),
|
|
643
|
+
trace=True,
|
|
644
|
+
)
|
|
382
645
|
# Opt-in pre-verify scope gate: this CLI subprocess wrote directly to disk with
|
|
383
646
|
# no per-write hook, so revert any out-of-scope change now (before it reaches the
|
|
384
647
|
# verify gate or a commit) rather than only flagging it as orphan_diff post-verify.
|
|
@@ -435,6 +698,26 @@ class CodingCliExecutor(Executor):
|
|
|
435
698
|
finally:
|
|
436
699
|
run_log_cm.__exit__(None, None, None)
|
|
437
700
|
|
|
701
|
+
def _build_env(self) -> dict[str, str]:
|
|
702
|
+
"""Environment for the agent subprocess.
|
|
703
|
+
|
|
704
|
+
Layering (later wins): parent process env → the agent spec's ``env`` (custom
|
|
705
|
+
agents) → the profile's ``env`` overrides → DevCouncil's own variables. The
|
|
706
|
+
profile layer is what lets a profile redirect the Claude Code harness at an
|
|
707
|
+
alternative Anthropic-compatible endpoint (``ANTHROPIC_BASE_URL`` /
|
|
708
|
+
``ANTHROPIC_AUTH_TOKEN`` / ``ANTHROPIC_MODEL``, e.g. a local LiteLLM proxy in
|
|
709
|
+
front of Ollama or OpenRouter) — the same provider-redirection trick DevPrism's
|
|
710
|
+
loopback proxy uses — without editing the base spec. DevCouncil's variables are
|
|
711
|
+
applied last so no profile can mask them."""
|
|
712
|
+
profile_env = dict(self.profile.env) if (self.profile and self.profile.env) else {}
|
|
713
|
+
return {
|
|
714
|
+
**dict(os.environ),
|
|
715
|
+
**self.spec.env,
|
|
716
|
+
**profile_env,
|
|
717
|
+
"DEVCOUNCIL_PROJECT_ROOT": str(self.project_root),
|
|
718
|
+
"DEVCOUNCIL_AGENT_PROFILE": self.profile_name,
|
|
719
|
+
}
|
|
720
|
+
|
|
438
721
|
def _scope_enforcement_enabled(self) -> bool:
|
|
439
722
|
try:
|
|
440
723
|
from devcouncil.app.config import load_config
|
|
@@ -442,6 +725,58 @@ class CodingCliExecutor(Executor):
|
|
|
442
725
|
except Exception:
|
|
443
726
|
return False
|
|
444
727
|
|
|
728
|
+
# Output signatures of failures caused by the NETWORK/PROVIDER, not the task.
|
|
729
|
+
# Matched case-insensitively against stderr plus the tail of stdout. Deliberately
|
|
730
|
+
# phrase-based (no bare status-code numbers) so code/diff content in stdout cannot
|
|
731
|
+
# spuriously classify a genuine agent failure as transient.
|
|
732
|
+
_TRANSIENT_FAILURE_MARKERS = (
|
|
733
|
+
"connection closed",
|
|
734
|
+
"connection reset",
|
|
735
|
+
"connection refused",
|
|
736
|
+
"connection error",
|
|
737
|
+
"connection aborted",
|
|
738
|
+
"econnreset",
|
|
739
|
+
"econnrefused",
|
|
740
|
+
"etimedout",
|
|
741
|
+
"socket hang up",
|
|
742
|
+
"mid-response",
|
|
743
|
+
"network error",
|
|
744
|
+
"fetch failed",
|
|
745
|
+
"temporarily unavailable",
|
|
746
|
+
"service unavailable",
|
|
747
|
+
"internal server error",
|
|
748
|
+
"bad gateway",
|
|
749
|
+
"gateway timeout",
|
|
750
|
+
"overloaded",
|
|
751
|
+
"rate limit",
|
|
752
|
+
"too many requests",
|
|
753
|
+
"request timed out",
|
|
754
|
+
"timeout awaiting",
|
|
755
|
+
"tls handshake",
|
|
756
|
+
"dns",
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
def _transient_failure_reason(self, result: "subprocess.CompletedProcess[str]") -> str | None:
|
|
760
|
+
"""The matched transient marker when a failed run looks network/provider-caused,
|
|
761
|
+
else None. Scans stderr fully and only the TAIL of stdout (where CLIs print
|
|
762
|
+
their final error) so large code output cannot trigger a false match."""
|
|
763
|
+
haystack = f"{result.stderr or ''}\n{(result.stdout or '')[-4000:]}".lower()
|
|
764
|
+
for marker in self._TRANSIENT_FAILURE_MARKERS:
|
|
765
|
+
if marker in haystack:
|
|
766
|
+
return marker
|
|
767
|
+
return None
|
|
768
|
+
|
|
769
|
+
def _transient_retry_limit(self) -> int:
|
|
770
|
+
"""Max transient-failure retries from ``execution.transient_retry_attempts``.
|
|
771
|
+
|
|
772
|
+
Defaults conservatively (2) when config is unavailable; 0 disables retry."""
|
|
773
|
+
try:
|
|
774
|
+
from devcouncil.app.config import load_config
|
|
775
|
+
|
|
776
|
+
return max(0, int(load_config(self.project_root).execution.transient_retry_attempts))
|
|
777
|
+
except Exception:
|
|
778
|
+
return 2
|
|
779
|
+
|
|
445
780
|
def _enforce_file_scope(self, task: Task) -> list[tuple[str, str]]:
|
|
446
781
|
"""Revert any file this task's subprocess changed that the task does not authorize.
|
|
447
782
|
|
|
@@ -453,8 +788,14 @@ class CodingCliExecutor(Executor):
|
|
|
453
788
|
from devcouncil.verification.verifier import Verifier
|
|
454
789
|
|
|
455
790
|
changed = Verifier(self.project_root).get_task_changed_files(task.id)
|
|
456
|
-
except Exception:
|
|
457
|
-
|
|
791
|
+
except Exception as exc:
|
|
792
|
+
logger.exception(
|
|
793
|
+
"Scope enforcement failed for %s: could not load changed files",
|
|
794
|
+
task.id,
|
|
795
|
+
)
|
|
796
|
+
raise RuntimeError(
|
|
797
|
+
f"Scope enforcement failed for {task.id}: could not determine changed files ({exc})."
|
|
798
|
+
) from exc
|
|
458
799
|
engine = TaskPolicyEngine(self.project_root)
|
|
459
800
|
reverted: list[tuple[str, str]] = []
|
|
460
801
|
for path in changed:
|
|
@@ -465,31 +806,43 @@ class CodingCliExecutor(Executor):
|
|
|
465
806
|
try:
|
|
466
807
|
decision = engine.evaluate_file_change(path, task, "write")
|
|
467
808
|
except Exception:
|
|
809
|
+
logger.warning(
|
|
810
|
+
"Scope enforcement skipped policy check for %s on %s",
|
|
811
|
+
task.id,
|
|
812
|
+
path,
|
|
813
|
+
exc_info=True,
|
|
814
|
+
)
|
|
468
815
|
continue
|
|
469
816
|
if decision.action == "deny" and self._revert_path(path):
|
|
470
817
|
reverted.append((path, decision.reason))
|
|
471
818
|
return reverted
|
|
472
819
|
|
|
820
|
+
_GIT_REVERT_TIMEOUT_SECONDS = 120
|
|
821
|
+
|
|
473
822
|
def _revert_path(self, rel_path: str) -> bool:
|
|
474
823
|
"""Undo an out-of-scope change. A file that exists in HEAD is restored to HEAD; a
|
|
475
824
|
file the task newly added (absent from HEAD, or no HEAD at all) is unstaged and
|
|
476
825
|
deleted. Best-effort — a failed revert returns False so the path is not reported as
|
|
477
826
|
cleanly gated (and the caller does not claim it was reverted)."""
|
|
827
|
+
timeout = self._GIT_REVERT_TIMEOUT_SECONDS
|
|
478
828
|
try:
|
|
479
829
|
in_head = subprocess.run(
|
|
480
830
|
["git", "cat-file", "-e", f"HEAD:{rel_path}"],
|
|
481
831
|
cwd=self.project_root, capture_output=True, text=True,
|
|
832
|
+
timeout=timeout,
|
|
482
833
|
).returncode == 0
|
|
483
834
|
if in_head:
|
|
484
835
|
return subprocess.run(
|
|
485
836
|
["git", "checkout", "HEAD", "--", rel_path],
|
|
486
837
|
cwd=self.project_root, capture_output=True, text=True,
|
|
838
|
+
timeout=timeout,
|
|
487
839
|
).returncode == 0
|
|
488
840
|
# New file (incl. the no-HEAD case): unstage if staged, then remove the working
|
|
489
841
|
# copy so it cannot be committed by the next repair attempt.
|
|
490
842
|
subprocess.run(
|
|
491
843
|
["git", "rm", "-f", "--cached", "--ignore-unmatch", rel_path],
|
|
492
844
|
cwd=self.project_root, capture_output=True, text=True,
|
|
845
|
+
timeout=timeout,
|
|
493
846
|
)
|
|
494
847
|
full = self.project_root / rel_path
|
|
495
848
|
if full.is_file():
|
|
@@ -541,6 +894,7 @@ class CodingCliExecutor(Executor):
|
|
|
541
894
|
input_text: str | None,
|
|
542
895
|
env: dict[str, str],
|
|
543
896
|
transcript_path: Path | None = None,
|
|
897
|
+
display_transform: Callable[[str], str | None] | None = None,
|
|
544
898
|
) -> subprocess.CompletedProcess[str]:
|
|
545
899
|
timeout = self._effective_timeout()
|
|
546
900
|
invocation = self._resolve_invocation(invocation, env)
|
|
@@ -625,7 +979,17 @@ class CodingCliExecutor(Executor):
|
|
|
625
979
|
continue
|
|
626
980
|
if line is None:
|
|
627
981
|
break
|
|
628
|
-
|
|
982
|
+
# Display may be transformed (e.g. Claude NDJSON → a readable line, or
|
|
983
|
+
# suppressed for noise), but capture and transcript always keep the raw
|
|
984
|
+
# bytes so post-run parsing and forensics see the full stream.
|
|
985
|
+
display: str | None = line
|
|
986
|
+
if display_transform is not None:
|
|
987
|
+
try:
|
|
988
|
+
display = display_transform(line)
|
|
989
|
+
except Exception:
|
|
990
|
+
display = line
|
|
991
|
+
if display:
|
|
992
|
+
self._emit_stream_line(display)
|
|
629
993
|
captured.append(line)
|
|
630
994
|
if transcript_handle is not None:
|
|
631
995
|
transcript_handle.write(redact_text(line))
|
|
@@ -655,6 +1019,18 @@ class CodingCliExecutor(Executor):
|
|
|
655
1019
|
return "off"
|
|
656
1020
|
return mode
|
|
657
1021
|
|
|
1022
|
+
def _grok_resume_mode(self) -> str:
|
|
1023
|
+
try:
|
|
1024
|
+
if self._config is None:
|
|
1025
|
+
mode = "off"
|
|
1026
|
+
else:
|
|
1027
|
+
mode = (self._config.execution.grok_resume_mode or "off").strip().lower()
|
|
1028
|
+
except Exception:
|
|
1029
|
+
mode = "off"
|
|
1030
|
+
if mode not in {"off", "project", "task"}:
|
|
1031
|
+
return "off"
|
|
1032
|
+
return mode
|
|
1033
|
+
|
|
658
1034
|
def _cursor_session_path(self, task_id: str | None = None) -> Path:
|
|
659
1035
|
if self._cursor_resume_mode() == "task" and task_id:
|
|
660
1036
|
return self.project_root / ".devcouncil" / "sessions" / f"{task_id}-cursor.json"
|
|
@@ -667,7 +1043,7 @@ class CodingCliExecutor(Executor):
|
|
|
667
1043
|
path = self._cursor_session_path(task_id if mode == "task" else None)
|
|
668
1044
|
if path.exists():
|
|
669
1045
|
try:
|
|
670
|
-
data =
|
|
1046
|
+
data = read_json(path) or {}
|
|
671
1047
|
except json.JSONDecodeError:
|
|
672
1048
|
data = {}
|
|
673
1049
|
existing_chat_id = str(data.get("chat_id") or "").strip()
|
|
@@ -677,7 +1053,7 @@ class CodingCliExecutor(Executor):
|
|
|
677
1053
|
if not ensured_chat_id:
|
|
678
1054
|
return None
|
|
679
1055
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
680
|
-
path
|
|
1056
|
+
write_json(path, {"chat_id": ensured_chat_id})
|
|
681
1057
|
return ensured_chat_id
|
|
682
1058
|
|
|
683
1059
|
def _ensure_cursor_chat_id(self) -> str | None:
|
|
@@ -701,6 +1077,292 @@ class CodingCliExecutor(Executor):
|
|
|
701
1077
|
chat_id = (result.stdout or result.stderr or "").strip().splitlines()[-1].strip()
|
|
702
1078
|
return chat_id or None
|
|
703
1079
|
|
|
1080
|
+
def _grok_session_path(self, task_id: str | None = None) -> Path:
|
|
1081
|
+
if self._grok_resume_mode() == "task" and task_id:
|
|
1082
|
+
return self.project_root / ".devcouncil" / "sessions" / f"{task_id}-grok.json"
|
|
1083
|
+
return self.project_root / ".devcouncil" / "integrations" / "grok-session.json"
|
|
1084
|
+
|
|
1085
|
+
def _grok_resume_session_id(self, task_id: str | None) -> str | None:
|
|
1086
|
+
mode = self._grok_resume_mode()
|
|
1087
|
+
if mode == "off":
|
|
1088
|
+
return None
|
|
1089
|
+
path = self._grok_session_path(task_id if mode == "task" else None)
|
|
1090
|
+
if path.exists():
|
|
1091
|
+
try:
|
|
1092
|
+
data = read_json(path) or {}
|
|
1093
|
+
except json.JSONDecodeError:
|
|
1094
|
+
data = {}
|
|
1095
|
+
existing = str(data.get("session_id") or "").strip()
|
|
1096
|
+
if existing:
|
|
1097
|
+
return existing
|
|
1098
|
+
return None
|
|
1099
|
+
|
|
1100
|
+
def _persist_grok_session(self, session_id: str, task_id: str | None) -> None:
|
|
1101
|
+
mode = self._grok_resume_mode()
|
|
1102
|
+
if mode == "off" or not session_id:
|
|
1103
|
+
return
|
|
1104
|
+
path = self._grok_session_path(task_id if mode == "task" else None)
|
|
1105
|
+
try:
|
|
1106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1107
|
+
write_json(path, {"session_id": session_id})
|
|
1108
|
+
except OSError:
|
|
1109
|
+
pass
|
|
1110
|
+
|
|
1111
|
+
def _capture_grok_session_from_result(
|
|
1112
|
+
self, task_id: str, result: subprocess.CompletedProcess[str]
|
|
1113
|
+
) -> None:
|
|
1114
|
+
"""Best-effort harvest of Grok session id from JSON output for resume."""
|
|
1115
|
+
if self._grok_resume_mode() == "off":
|
|
1116
|
+
return
|
|
1117
|
+
raw = (result.stdout or "").strip()
|
|
1118
|
+
if not raw:
|
|
1119
|
+
return
|
|
1120
|
+
payload: dict | None = None
|
|
1121
|
+
for line in reversed(raw.splitlines()):
|
|
1122
|
+
line = line.strip()
|
|
1123
|
+
if not line:
|
|
1124
|
+
continue
|
|
1125
|
+
try:
|
|
1126
|
+
candidate = json.loads(line)
|
|
1127
|
+
except json.JSONDecodeError:
|
|
1128
|
+
continue
|
|
1129
|
+
if isinstance(candidate, dict):
|
|
1130
|
+
payload = candidate
|
|
1131
|
+
break
|
|
1132
|
+
if payload is None:
|
|
1133
|
+
try:
|
|
1134
|
+
loaded = json.loads(raw)
|
|
1135
|
+
except json.JSONDecodeError:
|
|
1136
|
+
return
|
|
1137
|
+
payload = loaded if isinstance(loaded, dict) else None
|
|
1138
|
+
if payload is None:
|
|
1139
|
+
return
|
|
1140
|
+
session_id = str(payload.get("session_id") or payload.get("sessionId") or "").strip()
|
|
1141
|
+
if session_id:
|
|
1142
|
+
self.last_agent_session_id = session_id
|
|
1143
|
+
self._persist_grok_session(session_id, task_id)
|
|
1144
|
+
|
|
1145
|
+
def _claude_command(self, task_id: str | None = None) -> list[str]:
|
|
1146
|
+
"""Build Claude Code's headless command with a stable session identity.
|
|
1147
|
+
|
|
1148
|
+
A fresh task run is pinned to a new UUID via ``--session-id`` so DevCouncil
|
|
1149
|
+
knows, up front, which native ``~/.claude`` transcript the run will write —
|
|
1150
|
+
making headless evidence and live review locatable without scraping stdout.
|
|
1151
|
+
A re-run of the SAME task (e.g. a repair iteration) instead ``--resume``\\s the
|
|
1152
|
+
prior session so the model keeps the context of what it already tried, rather
|
|
1153
|
+
than restarting cold from a re-prepended correction manifest. The chosen id is
|
|
1154
|
+
stashed in ``_pending_claude_session`` and persisted once the run launches."""
|
|
1155
|
+
base = list(self.spec.base_command())
|
|
1156
|
+
# Capture Claude's structured result either way. Non-stream: one JSON blob parsed
|
|
1157
|
+
# after exit. Stream: NDJSON events rendered live (readable lines, not raw JSON) and
|
|
1158
|
+
# the terminal result event harvested for telemetry. ``--verbose`` is required by
|
|
1159
|
+
# Claude for stream-json.
|
|
1160
|
+
if self.stream_output:
|
|
1161
|
+
base = [*base, "--output-format", "stream-json", "--verbose"]
|
|
1162
|
+
else:
|
|
1163
|
+
base = [*base, "--output-format", "json"]
|
|
1164
|
+
session_path = self._claude_session_path(task_id)
|
|
1165
|
+
prior = self._read_claude_session_id(session_path)
|
|
1166
|
+
if prior:
|
|
1167
|
+
self._pending_claude_session = (session_path, prior)
|
|
1168
|
+
return [*base, "--resume", prior]
|
|
1169
|
+
new_id = str(uuid.uuid4())
|
|
1170
|
+
self._pending_claude_session = (session_path, new_id)
|
|
1171
|
+
return [*base, "--session-id", new_id]
|
|
1172
|
+
|
|
1173
|
+
def _claude_session_path(self, task_id: str | None) -> Path:
|
|
1174
|
+
name = f"{task_id}-claude.json" if task_id else "claude-session.json"
|
|
1175
|
+
return self.project_root / ".devcouncil" / "sessions" / name
|
|
1176
|
+
|
|
1177
|
+
@staticmethod
|
|
1178
|
+
def _read_claude_session_id(path: Path) -> str | None:
|
|
1179
|
+
if not path.exists():
|
|
1180
|
+
return None
|
|
1181
|
+
try:
|
|
1182
|
+
data = read_json(path) or {}
|
|
1183
|
+
except json.JSONDecodeError:
|
|
1184
|
+
return None
|
|
1185
|
+
session_id = str(data.get("session_id") or "").strip()
|
|
1186
|
+
return session_id or None
|
|
1187
|
+
|
|
1188
|
+
def _persist_claude_session(self) -> None:
|
|
1189
|
+
"""Record the run's Claude session id so a later re-run of the task resumes it.
|
|
1190
|
+
|
|
1191
|
+
Persisted after the subprocess launches (not on exit) so even a crashed or
|
|
1192
|
+
timed-out run leaves a resumable session behind. Also exposes the id via
|
|
1193
|
+
``last_agent_session_id`` for the caller and the run manifest."""
|
|
1194
|
+
pending = self._pending_claude_session
|
|
1195
|
+
if not pending:
|
|
1196
|
+
return
|
|
1197
|
+
path, session_id = pending
|
|
1198
|
+
self.last_agent_session_id = session_id
|
|
1199
|
+
try:
|
|
1200
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1201
|
+
write_json(path, {"session_id": session_id})
|
|
1202
|
+
except OSError:
|
|
1203
|
+
# A non-persisted session only costs a cold repair next time; never fail the run.
|
|
1204
|
+
pass
|
|
1205
|
+
|
|
1206
|
+
def _mirror_claude_transcript(self) -> None:
|
|
1207
|
+
"""Best-effort copy of Claude's native JSONL into the project for live review."""
|
|
1208
|
+
session_id = self.last_agent_session_id
|
|
1209
|
+
if not session_id:
|
|
1210
|
+
return
|
|
1211
|
+
try:
|
|
1212
|
+
from devcouncil.live.transcripts import mirror_claude_transcript
|
|
1213
|
+
|
|
1214
|
+
mirror_claude_transcript(self.project_root, session_id)
|
|
1215
|
+
except Exception as exc:
|
|
1216
|
+
logger.debug("Claude transcript mirror failed: %s", exc)
|
|
1217
|
+
|
|
1218
|
+
def _capture_claude_json(
|
|
1219
|
+
self, run_id: str, result: subprocess.CompletedProcess[str]
|
|
1220
|
+
) -> subprocess.CompletedProcess[str]:
|
|
1221
|
+
"""Parse Claude Code's ``--output-format json`` result blob.
|
|
1222
|
+
|
|
1223
|
+
Records the reported session id, cost, and token usage on the run manifest, then
|
|
1224
|
+
returns a copy of ``result`` with stdout replaced by the human-readable ``result``
|
|
1225
|
+
text so downstream logs/previews/diagnostics read like the old text mode. On any
|
|
1226
|
+
parse failure the original ``result`` is returned unchanged — a telemetry miss must
|
|
1227
|
+
never turn a successful run into a reported failure."""
|
|
1228
|
+
raw = (result.stdout or "").strip()
|
|
1229
|
+
if not raw:
|
|
1230
|
+
return result
|
|
1231
|
+
payload: dict | None = None
|
|
1232
|
+
# Tolerate any leading noise: scan lines from the last back for a JSON object.
|
|
1233
|
+
for line in reversed(raw.splitlines()):
|
|
1234
|
+
line = line.strip()
|
|
1235
|
+
if not line:
|
|
1236
|
+
continue
|
|
1237
|
+
try:
|
|
1238
|
+
candidate = json.loads(line)
|
|
1239
|
+
except json.JSONDecodeError:
|
|
1240
|
+
continue
|
|
1241
|
+
if isinstance(candidate, dict):
|
|
1242
|
+
payload = candidate
|
|
1243
|
+
break
|
|
1244
|
+
if payload is None:
|
|
1245
|
+
try:
|
|
1246
|
+
loaded = json.loads(raw)
|
|
1247
|
+
except json.JSONDecodeError:
|
|
1248
|
+
return result
|
|
1249
|
+
payload = loaded if isinstance(loaded, dict) else None
|
|
1250
|
+
if payload is None:
|
|
1251
|
+
return result
|
|
1252
|
+
|
|
1253
|
+
self._record_claude_result_meta(run_id, payload)
|
|
1254
|
+
text = payload.get("result")
|
|
1255
|
+
if isinstance(text, str) and text.strip():
|
|
1256
|
+
return subprocess.CompletedProcess(
|
|
1257
|
+
result.args, result.returncode, stdout=text, stderr=result.stderr
|
|
1258
|
+
)
|
|
1259
|
+
return result
|
|
1260
|
+
|
|
1261
|
+
def _record_claude_result_meta(self, run_id: str, payload: dict) -> None:
|
|
1262
|
+
"""Record session id, cost, and token usage from a Claude ``result`` payload.
|
|
1263
|
+
|
|
1264
|
+
Shared by the non-stream JSON path and the stream-json path; both surface the same
|
|
1265
|
+
terminal ``result`` object, just delivered as one blob vs. the last NDJSON line."""
|
|
1266
|
+
raw_usage = payload.get("usage")
|
|
1267
|
+
usage: dict[Any, Any] = raw_usage if isinstance(raw_usage, dict) else {}
|
|
1268
|
+
session_id = str(payload.get("session_id") or "").strip() or None
|
|
1269
|
+
if session_id:
|
|
1270
|
+
self.last_agent_session_id = session_id
|
|
1271
|
+
self._update_run_manifest(run_id, agent_session_id=session_id)
|
|
1272
|
+
# Keep the persisted resume pointer authoritative: if Claude reported a session
|
|
1273
|
+
# id different from the one we assigned, a later --resume must target the real
|
|
1274
|
+
# one, so rewrite the session file to what actually ran.
|
|
1275
|
+
pending = self._pending_claude_session
|
|
1276
|
+
if pending is not None and pending[1] != session_id:
|
|
1277
|
+
self._pending_claude_session = (pending[0], session_id)
|
|
1278
|
+
self._persist_claude_session()
|
|
1279
|
+
meta = {
|
|
1280
|
+
"session_id": session_id,
|
|
1281
|
+
"total_cost_usd": payload.get("total_cost_usd"),
|
|
1282
|
+
"num_turns": payload.get("num_turns"),
|
|
1283
|
+
"is_error": payload.get("is_error"),
|
|
1284
|
+
"input_tokens": usage.get("input_tokens"),
|
|
1285
|
+
"output_tokens": usage.get("output_tokens"),
|
|
1286
|
+
"cache_read_input_tokens": usage.get("cache_read_input_tokens"),
|
|
1287
|
+
}
|
|
1288
|
+
self._update_run_manifest(run_id, agent_result={k: v for k, v in meta.items() if v is not None})
|
|
1289
|
+
|
|
1290
|
+
def _capture_claude_stream_json(self, run_id: str, result: subprocess.CompletedProcess[str]) -> None:
|
|
1291
|
+
"""Record telemetry from a streamed NDJSON run by finding the terminal result event.
|
|
1292
|
+
|
|
1293
|
+
The streaming path already rendered readable lines live; here we only harvest the
|
|
1294
|
+
last ``result`` event for the manifest. Best-effort — never raises into the run."""
|
|
1295
|
+
payload: dict | None = None
|
|
1296
|
+
for line in (result.stdout or "").splitlines():
|
|
1297
|
+
line = line.strip()
|
|
1298
|
+
if not line:
|
|
1299
|
+
continue
|
|
1300
|
+
try:
|
|
1301
|
+
event = json.loads(line)
|
|
1302
|
+
except json.JSONDecodeError:
|
|
1303
|
+
continue
|
|
1304
|
+
if isinstance(event, dict) and event.get("type") == "result":
|
|
1305
|
+
payload = event # keep scanning; the last result event wins
|
|
1306
|
+
if payload is not None:
|
|
1307
|
+
self._record_claude_result_meta(run_id, payload)
|
|
1308
|
+
|
|
1309
|
+
@staticmethod
|
|
1310
|
+
def _render_claude_stream_event(line: str) -> str | None:
|
|
1311
|
+
"""Turn one Claude stream-json NDJSON line into a concise console line.
|
|
1312
|
+
|
|
1313
|
+
Returns the text to display, or None to suppress the event (system/thinking/rate-
|
|
1314
|
+
limit noise). Non-JSON lines pass through verbatim so nothing is silently dropped."""
|
|
1315
|
+
stripped = line.strip()
|
|
1316
|
+
if not stripped:
|
|
1317
|
+
return None
|
|
1318
|
+
try:
|
|
1319
|
+
event = json.loads(stripped)
|
|
1320
|
+
except json.JSONDecodeError:
|
|
1321
|
+
return line
|
|
1322
|
+
if not isinstance(event, dict):
|
|
1323
|
+
return line
|
|
1324
|
+
etype = event.get("type")
|
|
1325
|
+
if etype == "assistant":
|
|
1326
|
+
raw_message = event.get("message")
|
|
1327
|
+
message: dict[Any, Any] = raw_message if isinstance(raw_message, dict) else {}
|
|
1328
|
+
parts: list[str] = []
|
|
1329
|
+
for block in (message.get("content") or []):
|
|
1330
|
+
if not isinstance(block, dict):
|
|
1331
|
+
continue
|
|
1332
|
+
btype = block.get("type")
|
|
1333
|
+
if btype == "text" and str(block.get("text") or "").strip():
|
|
1334
|
+
parts.append(str(block["text"]).strip())
|
|
1335
|
+
elif btype in {"tool_use", "server_tool_use"}:
|
|
1336
|
+
name = str(block.get("name") or "tool")
|
|
1337
|
+
if name == "advisor" or (
|
|
1338
|
+
btype == "server_tool_use" and "advisor" in name.lower()
|
|
1339
|
+
):
|
|
1340
|
+
raw_input = block.get("input")
|
|
1341
|
+
tool_input: dict[Any, Any] = raw_input if isinstance(raw_input, dict) else {}
|
|
1342
|
+
model = str(
|
|
1343
|
+
tool_input.get("model")
|
|
1344
|
+
or block.get("model")
|
|
1345
|
+
or ""
|
|
1346
|
+
).strip()
|
|
1347
|
+
parts.append(f"Advising{f' ({model})' if model else ''}")
|
|
1348
|
+
continue
|
|
1349
|
+
raw_input = block.get("input")
|
|
1350
|
+
tool_input = raw_input if isinstance(raw_input, dict) else {}
|
|
1351
|
+
target = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("command") or ""
|
|
1352
|
+
parts.append(f"→ {name} {str(target)[:80]}".rstrip())
|
|
1353
|
+
text = "\n".join(p for p in parts if p)
|
|
1354
|
+
return text + "\n" if text else None
|
|
1355
|
+
if etype == "result":
|
|
1356
|
+
bits: list[str] = []
|
|
1357
|
+
if event.get("num_turns") is not None:
|
|
1358
|
+
bits.append(f"{event['num_turns']} turns")
|
|
1359
|
+
cost = event.get("total_cost_usd")
|
|
1360
|
+
if isinstance(cost, (int, float)):
|
|
1361
|
+
bits.append(f"${cost:.4f}")
|
|
1362
|
+
return ("✓ " + ", ".join(bits) + "\n") if bits else None
|
|
1363
|
+
# system / user / tool_result / rate_limit_event: keep the console quiet.
|
|
1364
|
+
return None
|
|
1365
|
+
|
|
704
1366
|
def _effective_timeout(self) -> int:
|
|
705
1367
|
if self.profile and self.profile.timeout_seconds:
|
|
706
1368
|
return int(self.profile.timeout_seconds)
|
|
@@ -713,6 +1375,9 @@ class CodingCliExecutor(Executor):
|
|
|
713
1375
|
for part in command
|
|
714
1376
|
]
|
|
715
1377
|
extra = list(self.profile.extra_args) if (self.profile and self.profile.extra_args) else []
|
|
1378
|
+
# Avoid a second --advisor when DevCouncil already attached one from advisor_model.
|
|
1379
|
+
if self._advisor_attached and extra:
|
|
1380
|
+
extra = strip_duplicate_advisor_args(extra)
|
|
716
1381
|
|
|
717
1382
|
def _place(base: list[str]) -> list[str]:
|
|
718
1383
|
"""Insert profile extra_args after the base flags but before a trailing prompt
|
|
@@ -743,6 +1408,30 @@ class CodingCliExecutor(Executor):
|
|
|
743
1408
|
def _display_invocation(self, invocation: list[str], prompt: str) -> list[str]:
|
|
744
1409
|
return [part.replace(prompt, "<task prompt>") for part in invocation]
|
|
745
1410
|
|
|
1411
|
+
@staticmethod
|
|
1412
|
+
def _inject_append_system_prompt(
|
|
1413
|
+
invocation: list[str], system_prompt: str, user_prompt: str
|
|
1414
|
+
) -> list[str]:
|
|
1415
|
+
"""Insert ``--append-system-prompt`` without splitting a trailing prompt value."""
|
|
1416
|
+
if invocation and invocation[-1] == user_prompt:
|
|
1417
|
+
return [*invocation[:-1], "--append-system-prompt", system_prompt, user_prompt]
|
|
1418
|
+
return [*invocation, "--append-system-prompt", system_prompt]
|
|
1419
|
+
|
|
1420
|
+
def _apply_advisor_steering(self, prompt: str, *, repair: bool) -> tuple[str, str | None]:
|
|
1421
|
+
"""Claude-only advisor steering. Prefer ``--append-system-prompt``; else prompt prefix.
|
|
1422
|
+
|
|
1423
|
+
Injects steering **only** when ``--advisor`` actually attached. Soft-skipped and
|
|
1424
|
+
non-Claude clients get no nudge. Returns ``(prompt, append_system_or_none)``.
|
|
1425
|
+
"""
|
|
1426
|
+
if self.client != "claude" or not self.profile or not self._advisor_attached:
|
|
1427
|
+
return prompt, None
|
|
1428
|
+
steer = advisor_steering_text(repair=repair)
|
|
1429
|
+
cost_trim = advisor_user_cost_trim()
|
|
1430
|
+
# Prefer system append when the flag is available; keep soft cost trim in user prompt.
|
|
1431
|
+
if claude_supports_append_system_prompt():
|
|
1432
|
+
return f"{cost_trim}\n\n{prompt}", steer
|
|
1433
|
+
return f"# DevCouncil Advisor\n{steer}\n\n{cost_trim}\n\n{prompt}", None
|
|
1434
|
+
|
|
746
1435
|
def _apply_profile_prompt(self, prompt: str) -> str:
|
|
747
1436
|
if not self.profile:
|
|
748
1437
|
return prompt
|
|
@@ -759,11 +1448,21 @@ class CodingCliExecutor(Executor):
|
|
|
759
1448
|
"""Resolved per-profile CLI overrides recorded in the manifest so a
|
|
760
1449
|
supervisor can see exactly how the profile constrained the invocation."""
|
|
761
1450
|
if not self.profile:
|
|
762
|
-
return {
|
|
1451
|
+
return {
|
|
1452
|
+
"extra_args": [],
|
|
1453
|
+
"permission_mode": None,
|
|
1454
|
+
"model": None,
|
|
1455
|
+
"advisor_model": None,
|
|
1456
|
+
"env_keys": [],
|
|
1457
|
+
}
|
|
763
1458
|
return {
|
|
764
1459
|
"extra_args": list(self.profile.extra_args or []),
|
|
765
1460
|
"permission_mode": self.profile.permission_mode,
|
|
766
1461
|
"model": self.profile.model,
|
|
1462
|
+
"advisor_model": self.profile.advisor_model,
|
|
1463
|
+
# KEY NAMES only — profile env values may carry provider tokens and must
|
|
1464
|
+
# never land in the manifest.
|
|
1465
|
+
"env_keys": sorted((self.profile.env or {}).keys()),
|
|
767
1466
|
}
|
|
768
1467
|
|
|
769
1468
|
def _update_run_manifest(self, run_id: str, **updates: object) -> None:
|
|
@@ -771,11 +1470,11 @@ class CodingCliExecutor(Executor):
|
|
|
771
1470
|
if not manifest_path.exists():
|
|
772
1471
|
return
|
|
773
1472
|
try:
|
|
774
|
-
manifest =
|
|
1473
|
+
manifest = read_json(manifest_path) or {}
|
|
775
1474
|
except json.JSONDecodeError:
|
|
776
1475
|
return
|
|
777
1476
|
manifest.update(updates)
|
|
778
|
-
manifest_path
|
|
1477
|
+
write_json(manifest_path, manifest)
|
|
779
1478
|
|
|
780
1479
|
def _preview_lines(self, value: str | None, *, limit: int = 20) -> list[str]:
|
|
781
1480
|
lines = redact_text(value or "").splitlines()
|
|
@@ -821,7 +1520,7 @@ class CodingCliExecutor(Executor):
|
|
|
821
1520
|
"finished_at": None,
|
|
822
1521
|
"duration_seconds": None,
|
|
823
1522
|
}
|
|
824
|
-
manifest_path
|
|
1523
|
+
write_json(manifest_path, manifest)
|
|
825
1524
|
return manifest_path
|
|
826
1525
|
|
|
827
1526
|
def _write_log(self, task_client: str, result: subprocess.CompletedProcess[str]) -> None:
|
|
@@ -855,10 +1554,10 @@ class CodingCliExecutor(Executor):
|
|
|
855
1554
|
should_write = not path.exists()
|
|
856
1555
|
if path.exists():
|
|
857
1556
|
try:
|
|
858
|
-
existing =
|
|
1557
|
+
existing = read_json(path) or {}
|
|
859
1558
|
except json.JSONDecodeError:
|
|
860
1559
|
existing = {}
|
|
861
1560
|
should_write = "mcpServers" in existing and "devcouncil" not in existing
|
|
862
1561
|
if should_write:
|
|
863
|
-
path
|
|
1562
|
+
write_json(path, desired)
|
|
864
1563
|
return path
|