devcouncil 0.3.1 → 0.4.1
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 +92 -33
- 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 +56 -40
- 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 +964 -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 +281 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -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 +87 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +103 -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 +69 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -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 +53 -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 +325 -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/summary.py +21 -3
- package/src/devcouncil/live/tasks.py +12 -3
- package/src/devcouncil/live/transcripts.py +69 -2
- package/src/devcouncil/llm/cache.py +5 -6
- package/src/devcouncil/llm/model_defaults.yaml +10 -10
- package/src/devcouncil/llm/provider.py +647 -73
- package/src/devcouncil/llm/router.py +271 -46
- package/src/devcouncil/llm/semantic_bridge.py +614 -0
- package/src/devcouncil/optimization/gepa_agent.py +6 -4
- package/src/devcouncil/optimization/skillopt.py +9 -5
- package/src/devcouncil/planning/arbiter_service.py +12 -3
- package/src/devcouncil/planning/correction_manifest.py +107 -10
- package/src/devcouncil/planning/plan_difficulty.py +69 -0
- package/src/devcouncil/planning/plan_service.py +5 -2
- package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
- package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
- package/src/devcouncil/planning/question_conversion.py +56 -0
- package/src/devcouncil/planning/spec_service.py +9 -3
- package/src/devcouncil/repo/ci_scaffold.py +197 -1
- package/src/devcouncil/repo/gitignore.py +1 -2
- package/src/devcouncil/reporting/evidence_export.py +124 -0
- package/src/devcouncil/reporting/evidence_html.py +210 -0
- package/src/devcouncil/reporting/json_report.py +16 -12
- package/src/devcouncil/reporting/markdown_report.py +38 -9
- package/src/devcouncil/reporting/mcp_resources.py +142 -0
- package/src/devcouncil/reporting/report_builder.py +40 -4
- package/src/devcouncil/reporting/task_provenance.py +42 -0
- package/src/devcouncil/reporting/verdict.py +75 -0
- package/src/devcouncil/skills/library/README.md +1 -0
- package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
- package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
- package/src/devcouncil/skills/library/devcouncil.md +93 -0
- package/src/devcouncil/skills/registry.py +43 -12
- package/src/devcouncil/storage/db.py +57 -11
- package/src/devcouncil/storage/models.py +6 -0
- package/src/devcouncil/storage/native.py +5 -3
- package/src/devcouncil/storage/repositories.py +50 -18
- package/src/devcouncil/telemetry/context.py +28 -0
- package/src/devcouncil/telemetry/cost.py +4 -5
- package/src/devcouncil/telemetry/logging_setup.py +78 -11
- package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
- package/src/devcouncil/telemetry/stages.py +27 -2
- package/src/devcouncil/telemetry/tracker.py +50 -13
- package/src/devcouncil/ui/dashboard.py +120 -8
- package/src/devcouncil/utils/fsio.py +58 -0
- package/src/devcouncil/utils/git_snapshot.py +112 -0
- package/src/devcouncil/utils/json_persist.py +53 -0
- package/src/devcouncil/utils/proc.py +89 -0
- package/src/devcouncil/verification/acceptance_compiler.py +36 -13
- package/src/devcouncil/verification/ad_hoc_check.py +95 -3
- package/src/devcouncil/verification/checks/__init__.py +41 -0
- package/src/devcouncil/verification/checks/acceptance.py +39 -0
- package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
- package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
- package/src/devcouncil/verification/checks/command_evidence.py +148 -0
- package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
- package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
- package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
- package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
- package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
- package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
- package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
- package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
- package/src/devcouncil/verification/checks/planned_files.py +98 -0
- package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
- package/src/devcouncil/verification/checks/stale_map.py +80 -0
- package/src/devcouncil/verification/checks/stub_scan.py +71 -0
- package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
- package/src/devcouncil/verification/checks/wiring.py +216 -0
- package/src/devcouncil/verification/claims/__init__.py +23 -0
- package/src/devcouncil/verification/claims/checks.py +395 -0
- package/src/devcouncil/verification/claims/mapper.py +168 -0
- package/src/devcouncil/verification/claims/models.py +39 -0
- package/src/devcouncil/verification/claims/transcript.py +92 -0
- package/src/devcouncil/verification/claims/verdict.py +88 -0
- package/src/devcouncil/verification/command_evidence.py +170 -0
- package/src/devcouncil/verification/command_malformation.py +147 -0
- package/src/devcouncil/verification/command_runner.py +164 -0
- package/src/devcouncil/verification/coverage_measurement.py +292 -0
- package/src/devcouncil/verification/diff_coverage.py +151 -0
- package/src/devcouncil/verification/difficulty.py +296 -0
- package/src/devcouncil/verification/effort_heuristics.py +178 -0
- package/src/devcouncil/verification/gap_ids.py +63 -0
- package/src/devcouncil/verification/gate_cache.py +194 -0
- package/src/devcouncil/verification/gate_selector.py +344 -0
- package/src/devcouncil/verification/git_diff_fallback.py +272 -0
- package/src/devcouncil/verification/implementation_reviewer.py +13 -0
- package/src/devcouncil/verification/incremental_check.py +241 -0
- package/src/devcouncil/verification/next_actions.py +60 -1
- package/src/devcouncil/verification/rigor_analytics.py +130 -0
- package/src/devcouncil/verification/sandbox.py +38 -11
- package/src/devcouncil/verification/stub_detector.py +369 -0
- package/src/devcouncil/verification/test_resolver.py +67 -1
- package/src/devcouncil/verification/verifier.py +137 -1666
- package/src/devcouncil/verification/verify_orchestration.py +610 -0
- package/src/devcouncil/verification/verify_setup.py +176 -0
- package/src/devcouncil/verification/wiki_refresh.py +208 -0
- package/src/semantic_layer/__init__.py +58 -0
- package/src/semantic_layer/benchmark.py +75 -0
- package/src/semantic_layer/cache.py +290 -0
- package/src/semantic_layer/compressor.py +137 -0
- package/src/semantic_layer/config.py +75 -0
- package/src/semantic_layer/embeddings.py +69 -0
- package/src/semantic_layer/llm_backends.py +99 -0
- package/src/semantic_layer/pipeline.py +111 -0
- package/src/semantic_layer/router.py +128 -0
- package/src/semantic_layer/tuner.py +72 -0
- package/uv.lock +973 -9
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/integrations/gitnexus.py +0 -70
- package/src/devcouncil/integrations/graphify.py +0 -34
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
"""Unified Stop / SubagentStop gate: claim checks + active-task verify."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from devcouncil.app.project_status import compute_phase
|
|
17
|
+
from devcouncil.live.transcripts import discover_sessions, latest_assistant_turn
|
|
18
|
+
from devcouncil.storage.db import get_db
|
|
19
|
+
from devcouncil.telemetry.traces import read_trace_events
|
|
20
|
+
|
|
21
|
+
from devcouncil.execution.stop_gate_history import append_event, build_event, last_event, session_tally
|
|
22
|
+
from devcouncil.execution.stop_gate_state import get_block_count, increment_block_count
|
|
23
|
+
from devcouncil.execution.stop_gate_verify_cache import load_verify_cache, record_verify_cache
|
|
24
|
+
from devcouncil.live.tasks import active_task_id
|
|
25
|
+
from devcouncil.telemetry.traces import TraceLogger
|
|
26
|
+
from devcouncil.verification.claims.checks import (
|
|
27
|
+
ClaimCheckBudget,
|
|
28
|
+
execute_checks,
|
|
29
|
+
resolve_commands_from_config,
|
|
30
|
+
)
|
|
31
|
+
from devcouncil.verification.claims.mapper import map_claims
|
|
32
|
+
from devcouncil.verification.claims.models import CheckResult, Status
|
|
33
|
+
from devcouncil.verification.claims.transcript import (
|
|
34
|
+
ends_on_open_question,
|
|
35
|
+
last_assistant_sentence,
|
|
36
|
+
last_assistant_text,
|
|
37
|
+
)
|
|
38
|
+
from devcouncil.verification.claims.verdict import decide_claims, summary_line
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class StopGateResult:
|
|
45
|
+
"""Outcome emitted to the coding-CLI Stop hook."""
|
|
46
|
+
|
|
47
|
+
decision: str # pass | assist | block
|
|
48
|
+
reason: str = ""
|
|
49
|
+
system_message: str | None = None
|
|
50
|
+
claim_results: list[CheckResult] = field(default_factory=list)
|
|
51
|
+
task_id: str | None = None
|
|
52
|
+
blocking_gaps: int = 0
|
|
53
|
+
next_actions: list[str] = field(default_factory=list)
|
|
54
|
+
mode: str = "off"
|
|
55
|
+
fail_open: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_mode(configured: str) -> str:
|
|
59
|
+
env = (os.environ.get("DEVCOUNCIL_STOP_GATE") or "").strip().lower()
|
|
60
|
+
if env in {"off", "assist", "block"}:
|
|
61
|
+
return env
|
|
62
|
+
mode = (configured or "off").strip().lower()
|
|
63
|
+
return mode if mode in {"off", "assist", "block"} else "off"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_stop_gate_config(project_root: Path) -> tuple[Any, Any]:
|
|
67
|
+
from devcouncil.app.config import load_config
|
|
68
|
+
|
|
69
|
+
cfg = load_config(project_root)
|
|
70
|
+
return cfg, cfg.execution.stop_gate
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_claim_pass(
|
|
74
|
+
project_root: Path,
|
|
75
|
+
claim_text: str,
|
|
76
|
+
*,
|
|
77
|
+
commands_cfg: object,
|
|
78
|
+
per_command_timeout: int,
|
|
79
|
+
total_timeout: int,
|
|
80
|
+
) -> list[CheckResult]:
|
|
81
|
+
assertions = map_claims(claim_text)
|
|
82
|
+
if not assertions:
|
|
83
|
+
return []
|
|
84
|
+
resolved = resolve_commands_from_config(commands_cfg)
|
|
85
|
+
budget = ClaimCheckBudget(
|
|
86
|
+
per_command_timeout=per_command_timeout,
|
|
87
|
+
total_timeout=total_timeout,
|
|
88
|
+
)
|
|
89
|
+
return execute_checks(assertions, cwd=project_root, commands=resolved, budget=budget)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run_task_verify(
|
|
93
|
+
project_root: Path,
|
|
94
|
+
*,
|
|
95
|
+
ttl_minutes: int,
|
|
96
|
+
) -> tuple[str | None, int, list[str], bool]:
|
|
97
|
+
"""Return (task_id, blocking_count, next_action_strings, used_cache)."""
|
|
98
|
+
task_id = active_task_id(project_root)
|
|
99
|
+
if not task_id:
|
|
100
|
+
return None, 0, [], False
|
|
101
|
+
|
|
102
|
+
cached = load_verify_cache(project_root, task_id=task_id, ttl_minutes=ttl_minutes)
|
|
103
|
+
if cached is not None:
|
|
104
|
+
actions = []
|
|
105
|
+
for item in cached.get("next_actions") or []:
|
|
106
|
+
if isinstance(item, dict) and item.get("action"):
|
|
107
|
+
actions.append(str(item["action"]))
|
|
108
|
+
elif isinstance(item, str):
|
|
109
|
+
actions.append(item)
|
|
110
|
+
return task_id, int(cached.get("blocking_gaps") or 0), actions[:10], True
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
import asyncio
|
|
114
|
+
|
|
115
|
+
from devcouncil.domain.evidence import CommandResult, DiffCoverageEvidence, DiffEvidence, TestEvidence
|
|
116
|
+
from devcouncil.storage.db import get_db
|
|
117
|
+
from devcouncil.storage.repositories import (
|
|
118
|
+
EvidenceRepository,
|
|
119
|
+
GapRepository,
|
|
120
|
+
RequirementRepository,
|
|
121
|
+
TaskRepository,
|
|
122
|
+
)
|
|
123
|
+
from devcouncil.verification.next_actions import split_next_actions
|
|
124
|
+
from devcouncil.verification.verifier import Verifier
|
|
125
|
+
|
|
126
|
+
db = get_db(project_root)
|
|
127
|
+
if not db:
|
|
128
|
+
return task_id, 0, [], False
|
|
129
|
+
with db.get_session() as session:
|
|
130
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
131
|
+
if not task:
|
|
132
|
+
return task_id, 0, [], False
|
|
133
|
+
reqs = RequirementRepository(session).get_all()
|
|
134
|
+
gaps, evidence = asyncio.run(Verifier(project_root).verify_task(task, reqs))
|
|
135
|
+
gap_repo = GapRepository(session)
|
|
136
|
+
ev_repo = EvidenceRepository(session)
|
|
137
|
+
gap_repo.delete_for_task(task.id)
|
|
138
|
+
ev_repo.delete_for_task(task.id)
|
|
139
|
+
for gap in gaps:
|
|
140
|
+
gap_repo.save(gap)
|
|
141
|
+
for ev in evidence:
|
|
142
|
+
if isinstance(ev, CommandResult):
|
|
143
|
+
ev_repo.save_command_result(task.id, ev)
|
|
144
|
+
elif isinstance(ev, DiffCoverageEvidence):
|
|
145
|
+
ev_repo.save_diff_coverage_evidence(ev)
|
|
146
|
+
elif isinstance(ev, DiffEvidence):
|
|
147
|
+
ev_repo.save_diff_evidence(ev)
|
|
148
|
+
elif isinstance(ev, TestEvidence):
|
|
149
|
+
ev_repo.save_test_evidence(ev, task.id)
|
|
150
|
+
blocking = [g for g in gaps if g.blocking]
|
|
151
|
+
task.status = "blocked" if blocking else "verified"
|
|
152
|
+
TaskRepository(session).save(task)
|
|
153
|
+
blocking_actions, _ = split_next_actions(gaps)
|
|
154
|
+
action_strs = [a.action for a in blocking_actions[:10]]
|
|
155
|
+
record_verify_cache(
|
|
156
|
+
project_root,
|
|
157
|
+
task_id=task_id,
|
|
158
|
+
status=task.status,
|
|
159
|
+
blocking_gaps=len(blocking),
|
|
160
|
+
next_actions=[a.model_dump() for a in blocking_actions[:20]],
|
|
161
|
+
passed=len(blocking) == 0,
|
|
162
|
+
)
|
|
163
|
+
return task_id, len(blocking), action_strs, False
|
|
164
|
+
except Exception as exc: # noqa: BLE001 — fail open inside claim of verify
|
|
165
|
+
logger.debug("stop_gate task verify failed: %s", exc)
|
|
166
|
+
return task_id, 0, [], False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _merge_corrective(
|
|
170
|
+
claim_reason: str,
|
|
171
|
+
task_id: str | None,
|
|
172
|
+
blocking_gaps: int,
|
|
173
|
+
next_actions: list[str],
|
|
174
|
+
) -> str:
|
|
175
|
+
parts: list[str] = []
|
|
176
|
+
if claim_reason:
|
|
177
|
+
parts.append(claim_reason)
|
|
178
|
+
if task_id and blocking_gaps:
|
|
179
|
+
parts.append(
|
|
180
|
+
f"ACTIVE TASK VERIFICATION FAILED for {task_id}: {blocking_gaps} blocking gap(s)."
|
|
181
|
+
)
|
|
182
|
+
if next_actions:
|
|
183
|
+
bullets = "\n".join(f"- {a}" for a in next_actions[:5])
|
|
184
|
+
parts.append("Next actions:\n" + bullets)
|
|
185
|
+
parts.append("Fix the gaps (or run `dev repair`), then re-verify before stopping.")
|
|
186
|
+
return "\n\n".join(parts)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _system_message(
|
|
190
|
+
*,
|
|
191
|
+
claim_results: list[CheckResult],
|
|
192
|
+
blocking_gaps: int,
|
|
193
|
+
decision: str,
|
|
194
|
+
notify_on_pass: bool,
|
|
195
|
+
) -> str | None:
|
|
196
|
+
claim_part = summary_line(claim_results) if claim_results else None
|
|
197
|
+
task_part = None
|
|
198
|
+
if blocking_gaps:
|
|
199
|
+
task_part = f"task blocked ({blocking_gaps} gap(s))"
|
|
200
|
+
elif decision == "pass" and notify_on_pass:
|
|
201
|
+
task_part = "task ✓" if claim_results else "stop-gate ✓"
|
|
202
|
+
|
|
203
|
+
if claim_part and task_part:
|
|
204
|
+
# summary_line already has prefix; append task note
|
|
205
|
+
return f"{claim_part} | {task_part}"
|
|
206
|
+
if claim_part:
|
|
207
|
+
return claim_part
|
|
208
|
+
if task_part:
|
|
209
|
+
return f"🛡 devcouncil: {task_part}"
|
|
210
|
+
if decision == "assist" and blocking_gaps == 0 and claim_results:
|
|
211
|
+
return claim_part
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def evaluate_stop(project_root: Path, payload: dict[str, Any] | None = None) -> StopGateResult:
|
|
216
|
+
"""Single entry for Stop / SubagentStop. Fail-open on internal errors."""
|
|
217
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
218
|
+
root = project_root.expanduser().resolve()
|
|
219
|
+
session_id = str(payload.get("session_id") or "unknown")
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
cfg, sg = _load_stop_gate_config(root)
|
|
223
|
+
mode = _resolve_mode(getattr(sg, "mode", "off"))
|
|
224
|
+
if mode == "off":
|
|
225
|
+
return StopGateResult(decision="pass", mode=mode)
|
|
226
|
+
|
|
227
|
+
stop_hook_active = bool(payload.get("stop_hook_active"))
|
|
228
|
+
blocks_so_far = get_block_count(root, session_id)
|
|
229
|
+
if stop_hook_active and blocks_so_far == 0:
|
|
230
|
+
TraceLogger(root).log_event(
|
|
231
|
+
"stop_gate_fail_open",
|
|
232
|
+
{"reason": "stop_hook_active_without_prior_block", "session_id": session_id},
|
|
233
|
+
summary="stop_gate fail-open (another hook blocked)",
|
|
234
|
+
)
|
|
235
|
+
return StopGateResult(decision="pass", mode=mode, fail_open=True)
|
|
236
|
+
|
|
237
|
+
claim_text = ""
|
|
238
|
+
transcript_path = payload.get("transcript_path")
|
|
239
|
+
if transcript_path and getattr(sg, "check_claims", True):
|
|
240
|
+
claim_text = last_assistant_text(Path(str(transcript_path))) or ""
|
|
241
|
+
# Allow direct claim injection for tests / non-Claude clients
|
|
242
|
+
if not claim_text and isinstance(payload.get("claim_text"), str):
|
|
243
|
+
claim_text = payload["claim_text"]
|
|
244
|
+
|
|
245
|
+
claim_results: list[CheckResult] = []
|
|
246
|
+
if getattr(sg, "check_claims", True) and claim_text:
|
|
247
|
+
claim_results = _run_claim_pass(
|
|
248
|
+
root,
|
|
249
|
+
claim_text,
|
|
250
|
+
commands_cfg=cfg.commands,
|
|
251
|
+
per_command_timeout=int(getattr(sg, "per_command_timeout", 90)),
|
|
252
|
+
total_timeout=int(getattr(sg, "total_timeout", 120)),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
task_id: str | None = None
|
|
256
|
+
blocking_gaps = 0
|
|
257
|
+
next_actions: list[str] = []
|
|
258
|
+
if getattr(sg, "verify_active_task", True):
|
|
259
|
+
task_id, blocking_gaps, next_actions, _ = _run_task_verify(
|
|
260
|
+
root,
|
|
261
|
+
ttl_minutes=int(getattr(sg, "verify_cache_minutes", 5)),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
max_blocks = int(getattr(sg, "max_blocks", 2))
|
|
265
|
+
claim_verdict = decide_claims(claim_results, blocks_so_far, max_blocks)
|
|
266
|
+
claim_failures = any(r.status is Status.FAIL for r in claim_results)
|
|
267
|
+
task_failures = blocking_gaps > 0
|
|
268
|
+
has_failure = claim_failures or task_failures
|
|
269
|
+
at_cap = blocks_so_far >= max_blocks
|
|
270
|
+
|
|
271
|
+
reason = _merge_corrective(
|
|
272
|
+
claim_verdict.reason if claim_verdict.block or (claim_failures and not at_cap) else "",
|
|
273
|
+
task_id,
|
|
274
|
+
blocking_gaps,
|
|
275
|
+
next_actions,
|
|
276
|
+
)
|
|
277
|
+
if claim_failures and not reason:
|
|
278
|
+
# At cap, decide_claims clears reason — still surface a short assist note.
|
|
279
|
+
reason = summary_line(claim_results)
|
|
280
|
+
|
|
281
|
+
notify_on_pass = bool(getattr(sg, "notify_on_pass", False))
|
|
282
|
+
|
|
283
|
+
if not has_failure:
|
|
284
|
+
decision = "pass"
|
|
285
|
+
sys_msg = _system_message(
|
|
286
|
+
claim_results=claim_results,
|
|
287
|
+
blocking_gaps=0,
|
|
288
|
+
decision=decision,
|
|
289
|
+
notify_on_pass=notify_on_pass,
|
|
290
|
+
)
|
|
291
|
+
elif mode == "block" and not at_cap:
|
|
292
|
+
decision = "block"
|
|
293
|
+
if not reason:
|
|
294
|
+
reason = _merge_corrective("", task_id, blocking_gaps, next_actions) or summary_line(
|
|
295
|
+
claim_results
|
|
296
|
+
)
|
|
297
|
+
sys_msg = _system_message(
|
|
298
|
+
claim_results=claim_results,
|
|
299
|
+
blocking_gaps=blocking_gaps,
|
|
300
|
+
decision=decision,
|
|
301
|
+
notify_on_pass=True,
|
|
302
|
+
)
|
|
303
|
+
increment_block_count(root, session_id)
|
|
304
|
+
else:
|
|
305
|
+
decision = "assist"
|
|
306
|
+
sys_msg = _system_message(
|
|
307
|
+
claim_results=claim_results,
|
|
308
|
+
blocking_gaps=blocking_gaps,
|
|
309
|
+
decision=decision,
|
|
310
|
+
notify_on_pass=True,
|
|
311
|
+
)
|
|
312
|
+
if reason and sys_msg:
|
|
313
|
+
sys_msg = f"{sys_msg}\n{reason}" if len(reason) < 800 else sys_msg
|
|
314
|
+
elif reason:
|
|
315
|
+
sys_msg = reason[:1200]
|
|
316
|
+
|
|
317
|
+
event = build_event(
|
|
318
|
+
session_id=session_id,
|
|
319
|
+
decision=decision,
|
|
320
|
+
claim=claim_text,
|
|
321
|
+
results=claim_results,
|
|
322
|
+
task_id=task_id,
|
|
323
|
+
blocking_gaps=blocking_gaps,
|
|
324
|
+
mode=mode,
|
|
325
|
+
)
|
|
326
|
+
append_event(root, event)
|
|
327
|
+
TraceLogger(root).log_event(
|
|
328
|
+
"stop_gate",
|
|
329
|
+
{
|
|
330
|
+
"session_id": session_id,
|
|
331
|
+
"decision": decision,
|
|
332
|
+
"mode": mode,
|
|
333
|
+
"task_id": task_id,
|
|
334
|
+
"blocking_gaps": blocking_gaps,
|
|
335
|
+
"claim_fail": sum(1 for r in claim_results if r.status is Status.FAIL),
|
|
336
|
+
"claim_pass": sum(1 for r in claim_results if r.status is Status.PASS),
|
|
337
|
+
},
|
|
338
|
+
task_id=task_id,
|
|
339
|
+
summary=f"stop_gate {decision} (mode={mode})",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return StopGateResult(
|
|
343
|
+
decision=decision,
|
|
344
|
+
reason=reason if decision == "block" else (reason if decision == "assist" else ""),
|
|
345
|
+
system_message=sys_msg,
|
|
346
|
+
claim_results=claim_results,
|
|
347
|
+
task_id=task_id,
|
|
348
|
+
blocking_gaps=blocking_gaps,
|
|
349
|
+
next_actions=next_actions,
|
|
350
|
+
mode=mode,
|
|
351
|
+
)
|
|
352
|
+
except Exception: # noqa: BLE001 — prime directive: fail open
|
|
353
|
+
try:
|
|
354
|
+
TraceLogger(root).log_event(
|
|
355
|
+
"stop_gate_error",
|
|
356
|
+
{"session_id": session_id, "traceback": traceback.format_exc()[-2000:]},
|
|
357
|
+
summary="stop_gate fail-open on internal error",
|
|
358
|
+
)
|
|
359
|
+
except Exception:
|
|
360
|
+
logger.debug("stop_gate fail-open logging failed", exc_info=True)
|
|
361
|
+
return StopGateResult(decision="pass", fail_open=True, mode="off")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def session_briefing(project_root: Path, payload: dict[str, Any] | None = None) -> str | None:
|
|
365
|
+
"""Full status + active-task context for normal SessionStart events."""
|
|
366
|
+
status = _status_line(project_root)
|
|
367
|
+
if not status:
|
|
368
|
+
return None
|
|
369
|
+
lines = [status]
|
|
370
|
+
task_id = active_task_id(project_root)
|
|
371
|
+
if task_id:
|
|
372
|
+
lines.append(f"Active task: {task_id}")
|
|
373
|
+
_, blocking = _phase_and_blocking_from_db(project_root)
|
|
374
|
+
if blocking:
|
|
375
|
+
lines.append(blocking)
|
|
376
|
+
|
|
377
|
+
# Append continuity extras if payload is passed
|
|
378
|
+
if payload is not None:
|
|
379
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
380
|
+
parts: list[str] = []
|
|
381
|
+
|
|
382
|
+
last = last_event(project_root)
|
|
383
|
+
if last:
|
|
384
|
+
decision = last.get("decision", "?")
|
|
385
|
+
claim = (last.get("claim") or "")[:120]
|
|
386
|
+
gaps = last.get("blocking_gaps", 0)
|
|
387
|
+
parts.append(
|
|
388
|
+
f"Where you left off: last stop-gate was `{decision}`"
|
|
389
|
+
+ (f" with {gaps} blocking gap(s)" if gaps else "")
|
|
390
|
+
+ (f"; claim: {claim!r}" if claim else "")
|
|
391
|
+
+ "."
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
transcript_path = payload.get("transcript_path")
|
|
395
|
+
if transcript_path:
|
|
396
|
+
tpath = Path(str(transcript_path))
|
|
397
|
+
sentence = last_assistant_sentence(tpath)
|
|
398
|
+
if sentence:
|
|
399
|
+
parts.append(f"Last assistant sentence: {sentence[:200]}")
|
|
400
|
+
if ends_on_open_question(tpath):
|
|
401
|
+
parts.append("Session appears to have ended on an open question.")
|
|
402
|
+
if parts:
|
|
403
|
+
lines.append("Blocking gaps/continuity: " + " ".join(parts))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
return "\n".join(lines)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
COMPACT_SNAPSHOT_REL = Path(".devcouncil") / "state" / "compact_snapshot.json"
|
|
410
|
+
_STOP_GATE_EVENT_TYPES = frozenset({"agent_response_ready", "subagent_stop", "post_task_verified"})
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def compact_snapshot_path(project_root: Path) -> Path:
|
|
414
|
+
return project_root / COMPACT_SNAPSHOT_REL
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
418
|
+
"""Atomically write JSON to ``path`` via a same-directory temp file."""
|
|
419
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
420
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
421
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
422
|
+
os.replace(tmp, path)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def read_compact_snapshot(project_root: Path) -> dict[str, Any] | None:
|
|
426
|
+
path = compact_snapshot_path(project_root)
|
|
427
|
+
if not path.exists():
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
431
|
+
return data if isinstance(data, dict) else None
|
|
432
|
+
except Exception:
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def compact_snapshot_recent(project_root: Path, within_seconds: int) -> bool:
|
|
437
|
+
"""True when a compact snapshot was written within ``within_seconds``."""
|
|
438
|
+
if within_seconds <= 0:
|
|
439
|
+
return False
|
|
440
|
+
snapshot = read_compact_snapshot(project_root)
|
|
441
|
+
if not snapshot:
|
|
442
|
+
return False
|
|
443
|
+
written_at = snapshot.get("written_at")
|
|
444
|
+
if not isinstance(written_at, str):
|
|
445
|
+
return False
|
|
446
|
+
try:
|
|
447
|
+
written = datetime.fromisoformat(written_at.replace("Z", "+00:00"))
|
|
448
|
+
if written.tzinfo is None:
|
|
449
|
+
written = written.replace(tzinfo=timezone.utc)
|
|
450
|
+
age = (datetime.now(timezone.utc) - written).total_seconds()
|
|
451
|
+
return 0 <= age <= within_seconds
|
|
452
|
+
except Exception:
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _status_line(root: Path) -> str | None:
|
|
457
|
+
try:
|
|
458
|
+
db = get_db(root)
|
|
459
|
+
if not db:
|
|
460
|
+
return None
|
|
461
|
+
from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
|
|
462
|
+
|
|
463
|
+
with db.get_session() as session:
|
|
464
|
+
graph = ArtifactGraphRepository(session).load_graph()
|
|
465
|
+
summary = graph.coverage_summary()
|
|
466
|
+
state = StateRepository(session).get_state()
|
|
467
|
+
phase = compute_phase(graph, state.current_phase if state else None)
|
|
468
|
+
return (
|
|
469
|
+
f"DevCouncil — phase: {phase}; tasks: {summary['total_tasks']}; "
|
|
470
|
+
f"gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking). "
|
|
471
|
+
"Use the devcouncil_* MCP tools and `dev` CLI to stay inside the verify loop."
|
|
472
|
+
)
|
|
473
|
+
except Exception:
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _phase_and_blocking_from_db(root: Path) -> tuple[str | None, str | None]:
|
|
478
|
+
try:
|
|
479
|
+
db = get_db(root)
|
|
480
|
+
if not db:
|
|
481
|
+
return None, None
|
|
482
|
+
from devcouncil.storage.repositories import GapRepository
|
|
483
|
+
|
|
484
|
+
with db.get_session() as session:
|
|
485
|
+
blocking = [g for g in GapRepository(session).get_all() if g.blocking]
|
|
486
|
+
phase = _project_phase(root)
|
|
487
|
+
return phase, _blocking_gaps_summary(blocking)
|
|
488
|
+
except Exception:
|
|
489
|
+
return None, None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _blocking_gaps_summary(blocking: list[Any]) -> str | None:
|
|
494
|
+
if not blocking:
|
|
495
|
+
return None
|
|
496
|
+
parts: list[str] = []
|
|
497
|
+
for gap in blocking[:5]:
|
|
498
|
+
label = getattr(gap, "description", None) or str(gap)
|
|
499
|
+
parts.append(str(label).split("\n", 1)[0][:120])
|
|
500
|
+
suffix = f" (+{len(blocking) - 5} more)" if len(blocking) > 5 else ""
|
|
501
|
+
return f"{len(blocking)} blocking gap(s): {'; '.join(parts)}{suffix}"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _last_stop_gate_event(root: Path) -> dict[str, Any] | None:
|
|
505
|
+
try:
|
|
506
|
+
for event in reversed(list(read_trace_events(root))):
|
|
507
|
+
if event.type in _STOP_GATE_EVENT_TYPES:
|
|
508
|
+
return {
|
|
509
|
+
"type": event.type,
|
|
510
|
+
"summary": event.summary,
|
|
511
|
+
"timestamp": event.timestamp,
|
|
512
|
+
"task_id": event.task_id,
|
|
513
|
+
}
|
|
514
|
+
except Exception:
|
|
515
|
+
pass
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _last_assistant_sentence(root: Path, session_id: str | None = None, payload: dict | None = None) -> str | None:
|
|
520
|
+
payload = payload or {}
|
|
521
|
+
tpath = payload.get("transcript_path") or payload.get("transcriptPath")
|
|
522
|
+
if tpath:
|
|
523
|
+
try:
|
|
524
|
+
return last_assistant_sentence(Path(str(tpath)))
|
|
525
|
+
except Exception:
|
|
526
|
+
pass
|
|
527
|
+
try:
|
|
528
|
+
sessions = discover_sessions(root, client="claude")
|
|
529
|
+
if session_id:
|
|
530
|
+
sessions = [s for s in sessions if s.id == session_id] or sessions
|
|
531
|
+
for session in sessions:
|
|
532
|
+
path = Path(session.transcript_path)
|
|
533
|
+
turn = latest_assistant_turn(path, client="claude")
|
|
534
|
+
if turn and turn.content.strip():
|
|
535
|
+
return _last_sentence(turn.content)
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _last_sentence(text: str) -> str:
|
|
543
|
+
cleaned = text.strip()
|
|
544
|
+
if not cleaned:
|
|
545
|
+
return ""
|
|
546
|
+
parts = re.split(r"(?<=[.!?])\s+", cleaned)
|
|
547
|
+
sentence = parts[-1].strip() if parts else cleaned
|
|
548
|
+
return sentence[:240]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def build_compact_snapshot(root: Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
552
|
+
session_id = payload.get("session_id") or payload.get("sessionId")
|
|
553
|
+
if session_id is not None:
|
|
554
|
+
session_id = str(session_id)
|
|
555
|
+
task_id = active_task_id(root)
|
|
556
|
+
phase, blocking_summary = _phase_and_blocking_from_db(root)
|
|
557
|
+
blocking_gaps = 0
|
|
558
|
+
next_actions: list[str] = []
|
|
559
|
+
if task_id:
|
|
560
|
+
blocking_gaps, next_actions = _task_blocking_summary(root, task_id)
|
|
561
|
+
last = last_event(root)
|
|
562
|
+
last_stop_gate = None
|
|
563
|
+
if last:
|
|
564
|
+
last_stop_gate = {
|
|
565
|
+
"decision": last.get("decision"),
|
|
566
|
+
"claim": (last.get("claim") or "")[:120],
|
|
567
|
+
"blocking_gaps": last.get("blocking_gaps", 0),
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
"written_at": datetime.now(timezone.utc).isoformat(),
|
|
571
|
+
"session_id": session_id,
|
|
572
|
+
"task_id": task_id,
|
|
573
|
+
"phase": phase,
|
|
574
|
+
"blocking_gaps_summary": blocking_summary,
|
|
575
|
+
"last_stop_gate_event": _last_stop_gate_event(root),
|
|
576
|
+
"last_assistant_sentence": _last_assistant_sentence(root, session_id, payload),
|
|
577
|
+
"blocking_gaps": blocking_gaps,
|
|
578
|
+
"next_actions": next_actions,
|
|
579
|
+
"last_stop_gate": last_stop_gate,
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _briefing_from_snapshot(snapshot: dict[str, Any]) -> str:
|
|
587
|
+
lines = ["DevCouncil (post-compaction recovery):"]
|
|
588
|
+
task_id = snapshot.get("task_id")
|
|
589
|
+
if task_id:
|
|
590
|
+
lines.append(f"Active task: {task_id}")
|
|
591
|
+
phase = snapshot.get("phase")
|
|
592
|
+
if phase:
|
|
593
|
+
lines.append(f"Phase: {phase}")
|
|
594
|
+
blocking = snapshot.get("blocking_gaps_summary")
|
|
595
|
+
if blocking:
|
|
596
|
+
lines.append(str(blocking))
|
|
597
|
+
stop_event = snapshot.get("last_stop_gate_event")
|
|
598
|
+
if isinstance(stop_event, dict) and stop_event.get("summary"):
|
|
599
|
+
lines.append(f"Last stop gate: {stop_event['summary']}")
|
|
600
|
+
last_sentence = snapshot.get("last_assistant_sentence")
|
|
601
|
+
if last_sentence:
|
|
602
|
+
lines.append(f"Last assistant context: {last_sentence}")
|
|
603
|
+
lines.append("Use devcouncil_* MCP tools and `dev` CLI to continue the verify loop.")
|
|
604
|
+
return "\n".join(lines)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _briefing_from_db(root: Path) -> str | None:
|
|
608
|
+
status = _status_line(root)
|
|
609
|
+
if not status:
|
|
610
|
+
return None
|
|
611
|
+
lines = ["DevCouncil (post-compaction recovery):", status.split(". Use the")[0]]
|
|
612
|
+
task_id = active_task_id(root)
|
|
613
|
+
if task_id:
|
|
614
|
+
lines.append(f"Active task: {task_id}")
|
|
615
|
+
_, blocking = _phase_and_blocking_from_db(root)
|
|
616
|
+
if blocking:
|
|
617
|
+
lines.append(blocking)
|
|
618
|
+
return "\n".join(lines)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _briefing_from_transcript(root: Path, session_id: str | None = None) -> str | None:
|
|
622
|
+
sentence = _last_assistant_sentence(root, session_id)
|
|
623
|
+
if not sentence:
|
|
624
|
+
return None
|
|
625
|
+
return f"DevCouncil (post-compaction recovery): Last assistant context: {sentence}"
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def compact_briefing(root: Path, payload: dict[str, Any] | None = None) -> str | None:
|
|
632
|
+
"""Slim post-compaction briefing — prefer snapshot, then DB, then transcript."""
|
|
633
|
+
payload = payload or {}
|
|
634
|
+
session_id = payload.get("session_id") or payload.get("sessionId")
|
|
635
|
+
if session_id is not None:
|
|
636
|
+
session_id = str(session_id)
|
|
637
|
+
|
|
638
|
+
snapshot = read_compact_snapshot(root)
|
|
639
|
+
if snapshot:
|
|
640
|
+
return _briefing_from_snapshot(snapshot)
|
|
641
|
+
|
|
642
|
+
db_brief = _briefing_from_db(root)
|
|
643
|
+
if db_brief:
|
|
644
|
+
return db_brief
|
|
645
|
+
|
|
646
|
+
return _briefing_from_transcript(root, session_id)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
LAST_COMPACT_BRIEF_REL = Path(".devcouncil") / "state" / "last_compact_brief.json"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def last_compact_brief_path(project_root: Path) -> Path:
|
|
653
|
+
return project_root / LAST_COMPACT_BRIEF_REL
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def write_compact_snapshot(project_root: Path, payload: dict[str, Any] | None = None) -> None:
|
|
657
|
+
path = compact_snapshot_path(project_root)
|
|
658
|
+
write_json(path, build_compact_snapshot(project_root, payload or {}))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def record_compact_brief(project_root: Path, session_id: str | None = None) -> None:
|
|
662
|
+
path = last_compact_brief_path(project_root)
|
|
663
|
+
write_json(path, {"ts": time.time(), "session_id": session_id})
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def recent_compact_brief(project_root: Path, within_seconds: int) -> bool:
|
|
667
|
+
if within_seconds <= 0:
|
|
668
|
+
return False
|
|
669
|
+
now = time.time()
|
|
670
|
+
try:
|
|
671
|
+
from devcouncil.utils.json_persist import read_json
|
|
672
|
+
data = read_json(last_compact_brief_path(project_root))
|
|
673
|
+
if isinstance(data, dict):
|
|
674
|
+
ts = data.get("ts")
|
|
675
|
+
if isinstance(ts, (int, float)) and (now - ts) <= within_seconds:
|
|
676
|
+
return True
|
|
677
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
678
|
+
pass
|
|
679
|
+
snap = read_compact_snapshot(project_root)
|
|
680
|
+
if snap:
|
|
681
|
+
ts = snap.get("ts")
|
|
682
|
+
if isinstance(ts, (int, float)) and (now - ts) <= within_seconds:
|
|
683
|
+
return True
|
|
684
|
+
written_at = snap.get("written_at")
|
|
685
|
+
if isinstance(written_at, str):
|
|
686
|
+
try:
|
|
687
|
+
written = datetime.fromisoformat(written_at.replace("Z", "+00:00"))
|
|
688
|
+
if written.tzinfo is None:
|
|
689
|
+
written = written.replace(tzinfo=timezone.utc)
|
|
690
|
+
age = (datetime.now(timezone.utc) - written).total_seconds()
|
|
691
|
+
return 0 <= age <= within_seconds
|
|
692
|
+
except Exception:
|
|
693
|
+
pass
|
|
694
|
+
return False
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def statusline_tally(project_root: Path, session_id: str | None) -> str | None:
|
|
698
|
+
"""Compact shield tally ``🛡 2✓ 1✗`` for the current session."""
|
|
699
|
+
if not session_id:
|
|
700
|
+
return None
|
|
701
|
+
ok, bad = session_tally(project_root, session_id)
|
|
702
|
+
if ok == 0 and bad == 0:
|
|
703
|
+
return None
|
|
704
|
+
return f"🛡 {ok}✓ {bad}✗"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _project_phase(project_root: Path) -> str | None:
|
|
708
|
+
try:
|
|
709
|
+
from devcouncil.app.project_status import compute_phase
|
|
710
|
+
from devcouncil.storage.db import get_db
|
|
711
|
+
from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
|
|
712
|
+
|
|
713
|
+
db = get_db(project_root)
|
|
714
|
+
if not db:
|
|
715
|
+
return None
|
|
716
|
+
with db.get_session() as session:
|
|
717
|
+
graph = ArtifactGraphRepository(session).load_graph()
|
|
718
|
+
state = StateRepository(session).get_state()
|
|
719
|
+
return compute_phase(graph, state.current_phase if state else None)
|
|
720
|
+
except Exception:
|
|
721
|
+
logger.debug("_project_phase failed", exc_info=True)
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _task_blocking_summary(project_root: Path, task_id: str | None) -> tuple[int, list[str]]:
|
|
726
|
+
if not task_id:
|
|
727
|
+
return 0, []
|
|
728
|
+
try:
|
|
729
|
+
from devcouncil.storage.db import get_db
|
|
730
|
+
from devcouncil.storage.repositories import GapRepository
|
|
731
|
+
from devcouncil.verification.next_actions import split_next_actions
|
|
732
|
+
|
|
733
|
+
db = get_db(project_root)
|
|
734
|
+
if not db:
|
|
735
|
+
return 0, []
|
|
736
|
+
with db.get_session() as session:
|
|
737
|
+
gaps = [g for g in GapRepository(session).get_for_task(task_id) if g.blocking]
|
|
738
|
+
if not gaps:
|
|
739
|
+
return 0, []
|
|
740
|
+
actions, _ = split_next_actions(gaps)
|
|
741
|
+
return len(gaps), [a.action for a in actions[:2]]
|
|
742
|
+
except Exception:
|
|
743
|
+
logger.debug("_task_blocking_summary failed", exc_info=True)
|
|
744
|
+
return 0, []
|
|
745
|
+
|
|
746
|
+
|