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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from typing import List, Dict
|
|
3
|
-
from pydantic import BaseModel
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
4
|
from devcouncil.domain.requirement import Requirement
|
|
5
5
|
from devcouncil.domain.task import Task
|
|
6
6
|
from devcouncil.llm.router import ModelRouter
|
|
@@ -8,8 +8,13 @@ from devcouncil.llm.router import ModelRouter
|
|
|
8
8
|
logger = logging.getLogger(__name__)
|
|
9
9
|
|
|
10
10
|
class ArbiterDecision(BaseModel):
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# Empty finding lists are routinely OMITTED (not sent as "[]") by models on
|
|
12
|
+
# providers without grammar-constrained decoding; an absent empty list is
|
|
13
|
+
# not an arbitration failure, so default instead of crashing the run.
|
|
14
|
+
# final_requirements/final_tasks stay required — a decision without them is
|
|
15
|
+
# a real failure the healing/retry path must surface.
|
|
16
|
+
accepted_finding_ids: List[str] = Field(default_factory=list)
|
|
17
|
+
rejected_finding_ids: List[Dict[str, str]] = Field(default_factory=list) # id, reason
|
|
13
18
|
final_requirements: List[Requirement]
|
|
14
19
|
final_tasks: List[Task]
|
|
15
20
|
|
|
@@ -48,6 +53,10 @@ You are the arbiter engineering manager. Your goal is to produce the final, defi
|
|
|
48
53
|
- High-severity unrefuted findings from critics must be incorporated into the final requirements or tasks.
|
|
49
54
|
- If a planner successfully rebutted a finding, you may skip it.
|
|
50
55
|
- Produce a single, coherent task graph.
|
|
56
|
+
- Set each task's ``difficulty`` field to ``easy``, ``normal``, or ``hard`` based on scope
|
|
57
|
+
(files touched, acceptance criteria count, cross-cutting concerns). Hard tasks get
|
|
58
|
+
stricter verification — prefer splitting work that would score hard into smaller tasks.
|
|
59
|
+
- Mention ``scaffolding`` in a task description only when intentional placeholders are expected.
|
|
51
60
|
"""
|
|
52
61
|
messages = [
|
|
53
62
|
{"role": "user", "content": prompt}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
import logging
|
|
7
6
|
import uuid
|
|
8
7
|
from datetime import datetime, timezone
|
|
9
8
|
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
10
|
|
|
11
11
|
from pydantic import BaseModel, Field
|
|
12
12
|
|
|
@@ -17,6 +17,7 @@ from devcouncil.storage.db import get_db
|
|
|
17
17
|
from devcouncil.storage.native import CorrectionManifestRepository
|
|
18
18
|
from devcouncil.storage.repositories import EvidenceRepository, GapRepository, TaskRepository
|
|
19
19
|
from devcouncil.utils.redaction import redact_text
|
|
20
|
+
from devcouncil.utils.json_persist import read_json, read_model_json, write_model_json
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
22
23
|
|
|
@@ -54,6 +55,17 @@ class CorrectionManifest(BaseModel):
|
|
|
54
55
|
# Both are redacted and size-bounded before being written.
|
|
55
56
|
prior_diff: str = ""
|
|
56
57
|
failing_output: str = ""
|
|
58
|
+
# One line per prior repair attempt (root cause it targeted + whether the
|
|
59
|
+
# blocking gaps changed afterwards), carried forward from the previous manifest
|
|
60
|
+
# so the agent sees its own trajectory instead of rediscovering it.
|
|
61
|
+
attempt_history: list[str] = Field(default_factory=list)
|
|
62
|
+
# Deterministic strategy guidance derived from the attempt state: "change
|
|
63
|
+
# approach" when the same gaps reproduced, "final attempt — analyze, don't
|
|
64
|
+
# stub" when the budget is nearly spent. Empty on a first attempt.
|
|
65
|
+
approach_guidance: str = ""
|
|
66
|
+
# ``file:line reason`` for every stub/placeholder the verifier detected, so the
|
|
67
|
+
# repair prompt names the exact placeholders to replace.
|
|
68
|
+
stub_findings: list[str] = Field(default_factory=list)
|
|
57
69
|
|
|
58
70
|
|
|
59
71
|
# Severity ordering: most severe first.
|
|
@@ -89,7 +101,13 @@ def remediable_incomplete_gaps(all_gaps: list[Gap]) -> list[Gap]:
|
|
|
89
101
|
_GAP_TYPE_PRIORITY = {
|
|
90
102
|
"test_failed": 0,
|
|
91
103
|
"acceptance_criteria_unproven": 1,
|
|
104
|
+
"coarse_acceptance_proof": 1,
|
|
92
105
|
"diff_not_exercised": 1,
|
|
106
|
+
"stub_detected": 1,
|
|
107
|
+
"unwired_file": 1,
|
|
108
|
+
"dead_symbol": 1,
|
|
109
|
+
"stranded_code": 1,
|
|
110
|
+
"stub_declared": 4,
|
|
93
111
|
"task_not_implemented": 2,
|
|
94
112
|
"migration_gap": 2,
|
|
95
113
|
"orphan_diff": 3,
|
|
@@ -97,6 +115,7 @@ _GAP_TYPE_PRIORITY = {
|
|
|
97
115
|
"dependency_risk": 3,
|
|
98
116
|
"architecture_drift": 4,
|
|
99
117
|
"assumption_violated": 4,
|
|
118
|
+
"suspicious_effort": 4,
|
|
100
119
|
"security_risk": 5,
|
|
101
120
|
}
|
|
102
121
|
|
|
@@ -123,11 +142,11 @@ def _latest_agent_run(project_root: Path, task_id: str) -> dict | None:
|
|
|
123
142
|
candidates = sorted(runs_dir.glob("*/agent-run.json"), reverse=True)
|
|
124
143
|
for path in candidates:
|
|
125
144
|
try:
|
|
126
|
-
payload =
|
|
145
|
+
payload = read_json(path)
|
|
127
146
|
except Exception:
|
|
128
147
|
continue
|
|
129
148
|
if payload.get("task_id") == task_id:
|
|
130
|
-
return payload
|
|
149
|
+
return cast(dict[Any, Any], payload)
|
|
131
150
|
return None
|
|
132
151
|
|
|
133
152
|
|
|
@@ -203,6 +222,51 @@ def _collect_failing_output(project_root: Path, failed_results) -> str:
|
|
|
203
222
|
return _truncate_tail(redact_text("\n\n".join(blocks)), _MAX_FAILING_OUTPUT_CHARS)
|
|
204
223
|
|
|
205
224
|
|
|
225
|
+
REPAIR_RULES = (
|
|
226
|
+
"## Repair rules (non-negotiable)\n"
|
|
227
|
+
"1. Only claim completion after every command in `commands_to_rerun` passes locally; "
|
|
228
|
+
"a claim without fresh passing evidence will be rejected by verification.\n"
|
|
229
|
+
"2. Never delete, skip, or weaken a test (or an assertion) to make verification pass.\n"
|
|
230
|
+
"3. Never satisfy a gap with a stub, placeholder, TODO, or hardcoded special-case.\n"
|
|
231
|
+
"4. If `prior_diff` is present, your previous edit was REJECTED — read `failing_output` "
|
|
232
|
+
"and fix the root cause; do not re-apply the same change.\n"
|
|
233
|
+
"5. If the criterion genuinely cannot be met, say so explicitly and explain what is "
|
|
234
|
+
"missing instead of faking progress.\n"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _build_attempt_history(prior: "CorrectionManifest | None", *, gaps_identical: bool) -> list[str]:
|
|
239
|
+
"""Carry the prior manifest's history forward and append one line for the attempt
|
|
240
|
+
that just failed. Empty on a first repair."""
|
|
241
|
+
if prior is None:
|
|
242
|
+
return []
|
|
243
|
+
history = list(prior.attempt_history)
|
|
244
|
+
outcome = "identical blocking gaps reproduced" if gaps_identical else "blocking gaps changed"
|
|
245
|
+
history.append(
|
|
246
|
+
f"attempt {max(1, prior.prior_failed_attempts)}: targeted '{prior.root_cause[:160]}'; {outcome}"
|
|
247
|
+
)
|
|
248
|
+
# Bounded so a long-running loop cannot swell the manifest.
|
|
249
|
+
return history[-10:]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _build_approach_guidance(
|
|
253
|
+
prior: "CorrectionManifest | None", prior_attempts: int, retry_budget: int, *, gaps_identical: bool
|
|
254
|
+
) -> str:
|
|
255
|
+
parts: list[str] = []
|
|
256
|
+
if prior is not None and gaps_identical:
|
|
257
|
+
parts.append(
|
|
258
|
+
"Your previous approach failed the same way (identical blocking gaps). Do NOT "
|
|
259
|
+
"retry the same edit; re-read the failing output and change strategy."
|
|
260
|
+
)
|
|
261
|
+
if retry_budget and prior_attempts >= retry_budget:
|
|
262
|
+
parts.append(
|
|
263
|
+
"This is the FINAL budgeted attempt. If an acceptance criterion cannot be met, "
|
|
264
|
+
"leave a clear written analysis of why (and what is missing) instead of "
|
|
265
|
+
"stubbing code or weakening tests."
|
|
266
|
+
)
|
|
267
|
+
return " ".join(parts)
|
|
268
|
+
|
|
269
|
+
|
|
206
270
|
def build_correction_manifest(
|
|
207
271
|
project_root: Path,
|
|
208
272
|
task: Task,
|
|
@@ -211,6 +275,7 @@ def build_correction_manifest(
|
|
|
211
275
|
repair_service=None,
|
|
212
276
|
prior_attempts: int = 0,
|
|
213
277
|
config=None,
|
|
278
|
+
prior_manifest: "CorrectionManifest | None" = None,
|
|
214
279
|
) -> CorrectionManifest:
|
|
215
280
|
# ``config`` may be threaded in by a caller that already loaded it (e.g. the repair
|
|
216
281
|
# loop, which would otherwise reload config from disk on every attempt). Fall back
|
|
@@ -234,10 +299,16 @@ def build_correction_manifest(
|
|
|
234
299
|
# not an arbitrary first gap such as an orphan_diff.
|
|
235
300
|
ordered_gaps = _ordered_blocking_gaps(blocking_gaps)
|
|
236
301
|
root_cause = ordered_gaps[0].description if ordered_gaps else "Unknown failure"
|
|
302
|
+
ordered_descriptions = [g.description for g in ordered_gaps]
|
|
303
|
+
gaps_identical = bool(
|
|
304
|
+
prior_manifest is not None
|
|
305
|
+
and prior_manifest.ordered_blocking_gaps == ordered_descriptions
|
|
306
|
+
)
|
|
307
|
+
retry_budget = config.execution.max_repair_attempts
|
|
237
308
|
manifest = CorrectionManifest(
|
|
238
309
|
task_id=task.id,
|
|
239
310
|
root_cause=root_cause,
|
|
240
|
-
ordered_blocking_gaps=
|
|
311
|
+
ordered_blocking_gaps=ordered_descriptions,
|
|
241
312
|
failed_evidence=failed,
|
|
242
313
|
allowed_repair_files=[pf.path for pf in task.planned_files],
|
|
243
314
|
forbidden_changes=list(task.forbidden_changes),
|
|
@@ -246,7 +317,7 @@ def build_correction_manifest(
|
|
|
246
317
|
# hardcoded 0. The agent sees how much of its budget is spent so it knows
|
|
247
318
|
# when to change approach rather than retry the same fix.
|
|
248
319
|
prior_failed_attempts=prior_attempts,
|
|
249
|
-
retry_budget=
|
|
320
|
+
retry_budget=retry_budget,
|
|
250
321
|
executor_recommendation=config.execution.default_executor,
|
|
251
322
|
created_at=datetime.now(timezone.utc).isoformat(),
|
|
252
323
|
# Prior-attempt context so the next executor repairs against what actually
|
|
@@ -254,6 +325,15 @@ def build_correction_manifest(
|
|
|
254
325
|
# the same wrong approach blind. Both are redacted and size-bounded.
|
|
255
326
|
prior_diff=_collect_prior_diff(project_root, task.id),
|
|
256
327
|
failing_output=_collect_failing_output(project_root, failed_results),
|
|
328
|
+
attempt_history=_build_attempt_history(prior_manifest, gaps_identical=gaps_identical),
|
|
329
|
+
approach_guidance=_build_approach_guidance(
|
|
330
|
+
prior_manifest, prior_attempts, retry_budget, gaps_identical=gaps_identical
|
|
331
|
+
),
|
|
332
|
+
stub_findings=[
|
|
333
|
+
f"{g.file}:{g.line} {g.description}" if g.file else g.description
|
|
334
|
+
for g in blocking_gaps
|
|
335
|
+
if g.gap_type == "stub_detected"
|
|
336
|
+
],
|
|
257
337
|
)
|
|
258
338
|
|
|
259
339
|
if repair_service is not None:
|
|
@@ -271,8 +351,9 @@ def build_correction_manifest(
|
|
|
271
351
|
manifest.allowed_repair_files, [pf.path for pf in suggested.planned_files]
|
|
272
352
|
)
|
|
273
353
|
manifest.commands_to_rerun = _union(manifest.commands_to_rerun, suggested.expected_tests)
|
|
274
|
-
except Exception:
|
|
275
|
-
|
|
354
|
+
except Exception as e:
|
|
355
|
+
# The manifest still ships, but without the repair plan's focused scope.
|
|
356
|
+
logger.warning("Repair plan generation failed, keeping heuristic manifest scope: %s", e)
|
|
276
357
|
return manifest
|
|
277
358
|
|
|
278
359
|
|
|
@@ -311,14 +392,18 @@ def write_correction_manifest(
|
|
|
311
392
|
"Writing correction manifest for %s: %d gap(s), prior_attempts=%d",
|
|
312
393
|
task_id, len(gaps), prior_attempts,
|
|
313
394
|
)
|
|
395
|
+
# The previous manifest (if any) feeds attempt_history and the identical-gaps
|
|
396
|
+
# comparison behind approach_guidance. Loaded BEFORE the new one is written.
|
|
397
|
+
prior_manifest = load_latest_correction_manifest(project_root, task_id)
|
|
314
398
|
manifest = build_correction_manifest(
|
|
315
|
-
project_root, task, gaps, repair_service=repair_service, prior_attempts=prior_attempts,
|
|
399
|
+
project_root, task, gaps, repair_service=repair_service, prior_attempts=prior_attempts,
|
|
400
|
+
config=config, prior_manifest=prior_manifest,
|
|
316
401
|
)
|
|
317
402
|
run_id = str(uuid.uuid4())
|
|
318
403
|
run_dir = project_root / ".devcouncil" / "runs" / run_id
|
|
319
404
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
320
405
|
path = run_dir / "correction-manifest.json"
|
|
321
|
-
path
|
|
406
|
+
write_model_json(path, manifest)
|
|
322
407
|
|
|
323
408
|
with db.get_session() as session:
|
|
324
409
|
CorrectionManifestRepository(session).save(
|
|
@@ -343,4 +428,16 @@ def load_latest_correction_manifest(project_root: Path, task_id: str) -> Correct
|
|
|
343
428
|
path = Path(record.manifest_path)
|
|
344
429
|
if not path.exists():
|
|
345
430
|
return None
|
|
346
|
-
return
|
|
431
|
+
return read_model_json(path, CorrectionManifest)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def repair_prompt_prefix(project_root: Path, task_id: str) -> str:
|
|
435
|
+
"""Correction manifest + repair rules for a repair run, or ``\"\"`` on first attempt."""
|
|
436
|
+
correction = load_latest_correction_manifest(project_root, task_id)
|
|
437
|
+
if correction is None:
|
|
438
|
+
return ""
|
|
439
|
+
return (
|
|
440
|
+
f"# DevCouncil Correction Manifest\n\n"
|
|
441
|
+
f"{correction.model_dump_json(indent=2)}\n\n"
|
|
442
|
+
f"{REPAIR_RULES}\n"
|
|
443
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Plan-time difficulty tagging so the council sees rigor before execution.
|
|
2
|
+
|
|
3
|
+
The verifier's deterministic estimator runs here on every task the arbiter emits
|
|
4
|
+
without an explicit ``Task.difficulty``. Overscoped tasks that score ``hard`` get
|
|
5
|
+
a human-visible warning so planners can split them before agents start work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import List, Tuple
|
|
12
|
+
|
|
13
|
+
from devcouncil.domain.requirement import Requirement
|
|
14
|
+
from devcouncil.domain.task import Task
|
|
15
|
+
from devcouncil.verification.difficulty import (
|
|
16
|
+
_HARD_THRESHOLD,
|
|
17
|
+
difficulty_score,
|
|
18
|
+
estimate_difficulty,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def annotate_tasks_with_difficulty(
|
|
25
|
+
tasks: List[Task],
|
|
26
|
+
requirements: List[Requirement] | None = None,
|
|
27
|
+
) -> List[Task]:
|
|
28
|
+
"""Set ``Task.difficulty`` from the estimator when the arbiter left it unset."""
|
|
29
|
+
annotated: List[Task] = []
|
|
30
|
+
for task in tasks:
|
|
31
|
+
if task.difficulty in ("easy", "normal", "hard"):
|
|
32
|
+
annotated.append(task)
|
|
33
|
+
continue
|
|
34
|
+
difficulty = estimate_difficulty(task, requirements)
|
|
35
|
+
annotated.append(task.model_copy(update={"difficulty": difficulty}))
|
|
36
|
+
return annotated
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def overscoped_hard_warnings(
|
|
40
|
+
tasks: List[Task],
|
|
41
|
+
requirements: List[Requirement] | None = None,
|
|
42
|
+
) -> List[str]:
|
|
43
|
+
"""Advisory messages for tasks that score hard and may need splitting."""
|
|
44
|
+
warnings: List[str] = []
|
|
45
|
+
for task in tasks:
|
|
46
|
+
score = difficulty_score(task, requirements)
|
|
47
|
+
if score < _HARD_THRESHOLD:
|
|
48
|
+
continue
|
|
49
|
+
writable = [pf for pf in task.planned_files if pf.allowed_change != "read_only"]
|
|
50
|
+
if len(writable) >= 4 or len(task.acceptance_criterion_ids) >= 5:
|
|
51
|
+
warnings.append(
|
|
52
|
+
f"{task.id} ({task.title!r}) scores hard ({score}): "
|
|
53
|
+
f"{len(writable)} writable file(s), "
|
|
54
|
+
f"{len(task.acceptance_criterion_ids)} acceptance criterion(s). "
|
|
55
|
+
"Consider splitting into smaller tasks before execution."
|
|
56
|
+
)
|
|
57
|
+
return warnings
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def apply_plan_difficulty(
|
|
61
|
+
tasks: List[Task],
|
|
62
|
+
requirements: List[Requirement] | None = None,
|
|
63
|
+
) -> Tuple[List[Task], List[str]]:
|
|
64
|
+
"""Annotate difficulty and return any overscoped-hard warnings."""
|
|
65
|
+
annotated = annotate_tasks_with_difficulty(tasks, requirements)
|
|
66
|
+
warnings = overscoped_hard_warnings(annotated, requirements)
|
|
67
|
+
for msg in warnings:
|
|
68
|
+
logger.info("Plan difficulty: %s", msg)
|
|
69
|
+
return annotated, warnings
|
|
@@ -8,8 +8,11 @@ from devcouncil.llm.router import ModelRouter
|
|
|
8
8
|
logger = logging.getLogger(__name__)
|
|
9
9
|
|
|
10
10
|
class PlanOutput(BaseModel):
|
|
11
|
-
id
|
|
12
|
-
|
|
11
|
+
# id/rationale are descriptive metadata some models omit; don't fail an
|
|
12
|
+
# otherwise-valid plan over them. tasks stays required — a plan without
|
|
13
|
+
# tasks is a real failure the healing/retry path must surface.
|
|
14
|
+
id: str = "PLAN"
|
|
15
|
+
rationale: str = ""
|
|
13
16
|
tasks: List[Task]
|
|
14
17
|
|
|
15
18
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Reconcile planner-emitted ``planned_files`` against the real repository.
|
|
2
|
+
|
|
3
|
+
The planner names ``planned_files`` as free-form LLM output. A
|
|
4
|
+
``modify``/``delete``/``read_only`` entry that points at a path the repo does not
|
|
5
|
+
actually contain is not a harmless typo: it becomes the file whitelist that
|
|
6
|
+
downstream scope enforcement trusts. A plausible-but-wrong path (a typo, or a file
|
|
7
|
+
that was renamed) then silently reverts the legitimate write the agent makes to the
|
|
8
|
+
*real* path, because the real path was never whitelisted.
|
|
9
|
+
|
|
10
|
+
This pass grounds those paths in the repo map's actual file set before the plan is
|
|
11
|
+
persisted. Its guiding invariant is **it only ever relaxes or corrects scope, never
|
|
12
|
+
tightens it** — so it can remove false reverts but never introduce a new one:
|
|
13
|
+
|
|
14
|
+
- ``create`` entries are left as-is (a new file legitimately isn't in the map yet).
|
|
15
|
+
- a path that exists in the map is kept (normalized).
|
|
16
|
+
- a non-existent ``modify``/``delete``/``read_only`` path is *repaired* to a real
|
|
17
|
+
path when exactly one file in the repo shares its basename (the typo/rename case).
|
|
18
|
+
- anything else (no basename match, or an ambiguous multi-match) is kept as-is and
|
|
19
|
+
reported as an advisory warning, never dropped.
|
|
20
|
+
|
|
21
|
+
Import-light (domain + stdlib only) so it stays unit-testable without booting the
|
|
22
|
+
planner's LLM/router stack.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Iterable
|
|
28
|
+
|
|
29
|
+
from devcouncil.domain.task import PlannedFile, Task
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize(path: str) -> str:
|
|
33
|
+
"""Strip ``./`` prefixes, normalize separators, and drop trailing slashes."""
|
|
34
|
+
return path.strip().replace("\\", "/").removeprefix("./").rstrip("/")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _basename(path: str) -> str:
|
|
38
|
+
return path.rsplit("/", 1)[-1]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def repo_files_from_map(repo_map: object) -> list[str]:
|
|
42
|
+
"""Extract tracked file paths from a ``RepoMap`` (or its ``dict`` form)."""
|
|
43
|
+
files = getattr(repo_map, "files", None)
|
|
44
|
+
if files is None and isinstance(repo_map, dict):
|
|
45
|
+
files = repo_map.get("files", [])
|
|
46
|
+
result: list[str] = []
|
|
47
|
+
for entry in files or []:
|
|
48
|
+
path = getattr(entry, "path", None)
|
|
49
|
+
if path is None and isinstance(entry, dict):
|
|
50
|
+
path = entry.get("path")
|
|
51
|
+
if path:
|
|
52
|
+
result.append(path)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def reconcile_planned_files(
|
|
57
|
+
tasks: list[Task],
|
|
58
|
+
repo_files: Iterable[str],
|
|
59
|
+
) -> tuple[list[Task], list[str]]:
|
|
60
|
+
"""Ground each task's ``planned_files`` against the real repo file set.
|
|
61
|
+
|
|
62
|
+
Returns the (possibly rewritten) tasks and a list of human-readable warnings
|
|
63
|
+
describing every repair or unresolved mismatch. When ``repo_files`` is empty
|
|
64
|
+
(no map available), tasks are returned untouched — we can't distinguish a
|
|
65
|
+
hallucinated path from a real one, so we degrade gracefully rather than guess.
|
|
66
|
+
"""
|
|
67
|
+
known = {_normalize(f) for f in repo_files if f and f.strip()}
|
|
68
|
+
|
|
69
|
+
if not known:
|
|
70
|
+
return list(tasks), []
|
|
71
|
+
|
|
72
|
+
by_basename: dict[str, set[str]] = {}
|
|
73
|
+
for f in known:
|
|
74
|
+
by_basename.setdefault(_basename(f), set()).add(f)
|
|
75
|
+
|
|
76
|
+
warnings: list[str] = []
|
|
77
|
+
new_tasks: list[Task] = []
|
|
78
|
+
|
|
79
|
+
for task in tasks:
|
|
80
|
+
if not task.planned_files:
|
|
81
|
+
new_tasks.append(task)
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
changed = False
|
|
85
|
+
kept: list[PlannedFile] = []
|
|
86
|
+
for pf in task.planned_files:
|
|
87
|
+
norm = _normalize(pf.path)
|
|
88
|
+
|
|
89
|
+
# A file this task creates legitimately isn't in the map yet.
|
|
90
|
+
if pf.allowed_change == "create" or norm in known:
|
|
91
|
+
if norm != pf.path:
|
|
92
|
+
kept.append(pf.model_copy(update={"path": norm}))
|
|
93
|
+
changed = True
|
|
94
|
+
else:
|
|
95
|
+
kept.append(pf)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Non-existent modify/delete/read_only target: repair on a unique
|
|
99
|
+
# basename match (the typo / rename case), otherwise keep + warn.
|
|
100
|
+
candidates = by_basename.get(_basename(norm), set())
|
|
101
|
+
if len(candidates) == 1:
|
|
102
|
+
repaired = next(iter(candidates))
|
|
103
|
+
kept.append(pf.model_copy(update={"path": repaired}))
|
|
104
|
+
changed = True
|
|
105
|
+
warnings.append(
|
|
106
|
+
f"{task.id}: planned file '{pf.path}' not found in repo; "
|
|
107
|
+
f"repaired to '{repaired}' (unique basename match)."
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
kept.append(pf.model_copy(update={"path": norm}) if norm != pf.path else pf)
|
|
111
|
+
if norm != pf.path:
|
|
112
|
+
changed = True
|
|
113
|
+
detail = (
|
|
114
|
+
f"{len(candidates)} basename matches — ambiguous"
|
|
115
|
+
if candidates
|
|
116
|
+
else "no matching file"
|
|
117
|
+
)
|
|
118
|
+
warnings.append(
|
|
119
|
+
f"{task.id}: planned file '{pf.path}' ({pf.allowed_change}) "
|
|
120
|
+
f"not found in repo ({detail}); left as-is for review."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
new_tasks.append(task.model_copy(update={"planned_files": kept}) if changed else task)
|
|
124
|
+
|
|
125
|
+
return new_tasks, warnings
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def expand_scope_with_dependents(
|
|
129
|
+
tasks: list[Task],
|
|
130
|
+
dependents: dict[str, list[str]],
|
|
131
|
+
repo_files: Iterable[str],
|
|
132
|
+
max_per_file: int = 8,
|
|
133
|
+
) -> tuple[list[Task], list[str]]:
|
|
134
|
+
"""Widen each task's ``planned_files`` with the real callers of its writable files.
|
|
135
|
+
|
|
136
|
+
The other half of the scope bug ``reconcile_planned_files`` doesn't cover: the
|
|
137
|
+
planner names ``foo.py`` to modify but not the files that *import* ``foo.py`` and
|
|
138
|
+
must change with it. Those omitted callers are outside the whitelist, so the
|
|
139
|
+
agent's necessary edit to them is reverted. Using the repo map's ``dependents``
|
|
140
|
+
(reverse import edges), add each writable file's callers as ``modify``-scoped
|
|
141
|
+
entries — capped per file and drawn only from files that actually exist, so this
|
|
142
|
+
only ever *relaxes* scope from a grounded signal, never invents paths.
|
|
143
|
+
"""
|
|
144
|
+
if not dependents:
|
|
145
|
+
return list(tasks), []
|
|
146
|
+
|
|
147
|
+
known = {_normalize(f) for f in repo_files if f and f.strip()}
|
|
148
|
+
norm_dependents = {
|
|
149
|
+
_normalize(k): [_normalize(v) for v in (vs or [])] for k, vs in dependents.items()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
warnings: list[str] = []
|
|
153
|
+
new_tasks: list[Task] = []
|
|
154
|
+
|
|
155
|
+
for task in tasks:
|
|
156
|
+
existing = {_normalize(pf.path) for pf in task.planned_files}
|
|
157
|
+
additions: list[PlannedFile] = []
|
|
158
|
+
for pf in task.planned_files:
|
|
159
|
+
if pf.allowed_change not in ("modify", "delete"):
|
|
160
|
+
continue
|
|
161
|
+
norm = _normalize(pf.path)
|
|
162
|
+
added = 0
|
|
163
|
+
for dep in norm_dependents.get(norm, []):
|
|
164
|
+
if added >= max_per_file:
|
|
165
|
+
break
|
|
166
|
+
if dep in existing:
|
|
167
|
+
continue
|
|
168
|
+
if known and dep not in known:
|
|
169
|
+
continue
|
|
170
|
+
existing.add(dep)
|
|
171
|
+
additions.append(
|
|
172
|
+
PlannedFile(
|
|
173
|
+
path=dep,
|
|
174
|
+
reason=f"imports {norm}; may need updating when it changes (repo map dependents)",
|
|
175
|
+
allowed_change="modify",
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
added += 1
|
|
179
|
+
|
|
180
|
+
if additions:
|
|
181
|
+
new_tasks.append(
|
|
182
|
+
task.model_copy(update={"planned_files": [*task.planned_files, *additions]})
|
|
183
|
+
)
|
|
184
|
+
warnings.append(
|
|
185
|
+
f"{task.id}: widened scope with {len(additions)} dependent file(s) so a "
|
|
186
|
+
"required caller edit isn't reverted."
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
new_tasks.append(task)
|
|
190
|
+
|
|
191
|
+
return new_tasks, warnings
|
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
from pydantic import BaseModel, Field
|
|
5
5
|
|
|
6
6
|
from devcouncil.llm.router import ModelRouter
|
|
7
|
+
from devcouncil.utils.json_persist import read_model_json, write_model_json
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
9
10
|
|
|
@@ -97,11 +98,10 @@ def load_latest_prompt_enhancement(project_root: Path) -> "PromptEnhancement | N
|
|
|
97
98
|
was persisted (so the executor gets the guidance tied to the plan it is running). Falls
|
|
98
99
|
back to the most recent per-run artifact for plans persisted before that file existed.
|
|
99
100
|
Best-effort: any read/parse failure returns None so prompt building never breaks."""
|
|
100
|
-
import json
|
|
101
101
|
|
|
102
102
|
def _load(path: Path) -> "PromptEnhancement | None":
|
|
103
103
|
try:
|
|
104
|
-
return
|
|
104
|
+
return read_model_json(path, PromptEnhancement)
|
|
105
105
|
except Exception:
|
|
106
106
|
return None
|
|
107
107
|
|
|
@@ -125,9 +125,10 @@ def save_active_prompt_enhancement(project_root: Path, enhancement: "PromptEnhan
|
|
|
125
125
|
try:
|
|
126
126
|
path = project_root / ".devcouncil" / _ACTIVE_ENHANCEMENT_FILE
|
|
127
127
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
-
path
|
|
129
|
-
except Exception:
|
|
130
|
-
|
|
128
|
+
write_model_json(path, enhancement)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
# If this persistence fails, executors silently run without (or with stale) guidance.
|
|
131
|
+
logger.warning("Failed to persist active prompt enhancement: %s", e)
|
|
131
132
|
|
|
132
133
|
|
|
133
134
|
class PromptEnhancerService:
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Convert blocking spec questions into non-blocking assumptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devcouncil.domain.assumption import Assumption
|
|
6
|
+
from devcouncil.planning.spec_service import BlockingQuestion
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def assumption_id_from_question(question_id: str) -> str:
|
|
10
|
+
"""Derive a stable assumption id from a blocking-question id."""
|
|
11
|
+
if question_id.startswith("ASM-"):
|
|
12
|
+
return question_id
|
|
13
|
+
if question_id.startswith("Q-"):
|
|
14
|
+
return f"ASM-from-{question_id}"
|
|
15
|
+
return f"ASM-from-Q-{question_id}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def blocking_question_to_assumption(question: BlockingQuestion) -> Assumption:
|
|
19
|
+
"""Map one blocking question to a low-confidence assumption that does not block approval."""
|
|
20
|
+
statement = question.question
|
|
21
|
+
if question.reason:
|
|
22
|
+
statement = f"{question.question} (Context: {question.reason})"
|
|
23
|
+
return Assumption(
|
|
24
|
+
id=assumption_id_from_question(question.id),
|
|
25
|
+
statement=statement,
|
|
26
|
+
confidence="low",
|
|
27
|
+
impact="medium",
|
|
28
|
+
reversible=True,
|
|
29
|
+
requires_user_confirmation=False,
|
|
30
|
+
status="open",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def convert_blocking_questions_to_assumptions(
|
|
35
|
+
assumptions: list[Assumption],
|
|
36
|
+
blocking_questions: list[BlockingQuestion],
|
|
37
|
+
) -> tuple[list[Assumption], list[BlockingQuestion]]:
|
|
38
|
+
"""Append converted questions as assumptions and return an empty blocking list."""
|
|
39
|
+
if not blocking_questions:
|
|
40
|
+
return assumptions, blocking_questions
|
|
41
|
+
|
|
42
|
+
existing_ids = {assumption.id for assumption in assumptions}
|
|
43
|
+
converted: list[Assumption] = []
|
|
44
|
+
for question in blocking_questions:
|
|
45
|
+
assumption = blocking_question_to_assumption(question)
|
|
46
|
+
candidate_id = assumption.id
|
|
47
|
+
suffix = 1
|
|
48
|
+
while candidate_id in existing_ids:
|
|
49
|
+
candidate_id = f"{assumption.id}-{suffix}"
|
|
50
|
+
suffix += 1
|
|
51
|
+
if candidate_id != assumption.id:
|
|
52
|
+
assumption = assumption.model_copy(update={"id": candidate_id})
|
|
53
|
+
existing_ids.add(assumption.id)
|
|
54
|
+
converted.append(assumption)
|
|
55
|
+
|
|
56
|
+
return [*assumptions, *converted], []
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from typing import List
|
|
3
|
-
from pydantic import BaseModel
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
4
|
from devcouncil.domain.requirement import Requirement
|
|
5
5
|
from devcouncil.domain.assumption import Assumption
|
|
6
6
|
from devcouncil.llm.router import ModelRouter
|
|
@@ -14,8 +14,14 @@ class BlockingQuestion(BaseModel):
|
|
|
14
14
|
|
|
15
15
|
class SpecOutput(BaseModel):
|
|
16
16
|
requirements: List[Requirement]
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
# Models on providers without grammar-constrained decoding (e.g.
|
|
18
|
+
# gemini-2.5-flash via OpenRouter) routinely OMIT list fields that would be
|
|
19
|
+
# empty instead of emitting "[]". An absent empty list is semantically
|
|
20
|
+
# identical to an empty one, so default rather than crash the planning run
|
|
21
|
+
# with a "Field required" validation error. requirements stays required:
|
|
22
|
+
# a spec without requirements IS a failure worth surfacing.
|
|
23
|
+
assumptions: List[Assumption] = Field(default_factory=list)
|
|
24
|
+
blocking_questions: List[BlockingQuestion] = Field(default_factory=list)
|
|
19
25
|
|
|
20
26
|
class SpecService:
|
|
21
27
|
def __init__(self, router: ModelRouter):
|