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,5 +1,7 @@
|
|
|
1
1
|
import typer
|
|
2
2
|
import json
|
|
3
|
+
from devcouncil.utils.json_persist import dump_json
|
|
4
|
+
import logging
|
|
3
5
|
import os
|
|
4
6
|
import sys
|
|
5
7
|
from pathlib import Path
|
|
@@ -8,18 +10,25 @@ from devcouncil.storage.db import get_db
|
|
|
8
10
|
from devcouncil.storage.repositories import TaskRepository
|
|
9
11
|
from devcouncil.execution.hook_policy import HookPolicy
|
|
10
12
|
from devcouncil.telemetry.traces import TraceLogger
|
|
13
|
+
from devcouncil.telemetry.stages import log_step
|
|
11
14
|
from devcouncil.live.signals import write_signal
|
|
12
15
|
from devcouncil.live.tasks import active_task_id
|
|
13
16
|
|
|
14
17
|
app = typer.Typer()
|
|
15
18
|
console = Console()
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
def _project_root(project_root: Path | None = None) -> Path:
|
|
19
23
|
if project_root:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
root = project_root.expanduser().resolve()
|
|
25
|
+
else:
|
|
26
|
+
configured = os.environ.get("DEVCOUNCIL_PROJECT_ROOT")
|
|
27
|
+
root = Path(configured).expanduser().resolve() if configured else Path(".").resolve()
|
|
28
|
+
from devcouncil.telemetry.logging_setup import set_log_dir
|
|
29
|
+
|
|
30
|
+
set_log_dir(root)
|
|
31
|
+
return root
|
|
23
32
|
|
|
24
33
|
|
|
25
34
|
def _active_task(root: Path):
|
|
@@ -36,16 +45,109 @@ def _active_task(root: Path):
|
|
|
36
45
|
return TaskRepository(session).get_by_id(active_id)
|
|
37
46
|
|
|
38
47
|
|
|
48
|
+
def _emit_stop_result(client: str, result) -> None:
|
|
49
|
+
"""Emit the client's native Stop result schema."""
|
|
50
|
+
from devcouncil.execution.stop_gate import StopGateResult
|
|
51
|
+
|
|
52
|
+
if not isinstance(result, StopGateResult):
|
|
53
|
+
return
|
|
54
|
+
normalized = client.lower()
|
|
55
|
+
if result.decision == "block" and result.reason:
|
|
56
|
+
if normalized == "codex":
|
|
57
|
+
payload: dict[str, object] = {
|
|
58
|
+
"continue": False,
|
|
59
|
+
"stopReason": result.reason,
|
|
60
|
+
}
|
|
61
|
+
if result.system_message:
|
|
62
|
+
payload["systemMessage"] = result.system_message
|
|
63
|
+
print(dump_json(payload, separators=(",", ":")))
|
|
64
|
+
return
|
|
65
|
+
payload = {"decision": "block", "reason": result.reason}
|
|
66
|
+
if result.system_message:
|
|
67
|
+
payload["systemMessage"] = result.system_message
|
|
68
|
+
print(dump_json(payload, separators=(",", ":")))
|
|
69
|
+
return
|
|
70
|
+
if normalized == "gemini":
|
|
71
|
+
payload = {"decision": "allow", "suppressOutput": True}
|
|
72
|
+
if result.system_message:
|
|
73
|
+
payload["systemMessage"] = result.system_message
|
|
74
|
+
print(dump_json(payload, separators=(",", ":")))
|
|
75
|
+
return
|
|
76
|
+
if result.system_message:
|
|
77
|
+
print(dump_json({"systemMessage": result.system_message}, separators=(",", ":")))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _handle_unified_stop(
|
|
81
|
+
event_json: str | None,
|
|
82
|
+
*,
|
|
83
|
+
client: str,
|
|
84
|
+
project_root: Path | None,
|
|
85
|
+
hook_kind: str,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Shared Stop / SubagentStop / post_task orchestrator."""
|
|
88
|
+
payload_text = event_json if event_json is not None else sys.stdin.read()
|
|
89
|
+
root = _project_root(project_root)
|
|
90
|
+
try:
|
|
91
|
+
payload = json.loads(payload_text) if payload_text.strip() else {}
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
payload = {"raw": payload_text}
|
|
94
|
+
if not isinstance(payload, dict):
|
|
95
|
+
payload = {}
|
|
96
|
+
|
|
97
|
+
if not any(key in payload for key in ("task_id", "taskId", "task")):
|
|
98
|
+
try:
|
|
99
|
+
active_id = active_task_id(root)
|
|
100
|
+
except Exception:
|
|
101
|
+
active_id = None
|
|
102
|
+
if active_id:
|
|
103
|
+
payload["task_id"] = active_id
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
signal_path = write_signal(root, client.lower(), payload)
|
|
107
|
+
trace_type = "agent_response_ready" if hook_kind == "stop" else "subagent_stop"
|
|
108
|
+
summary = (
|
|
109
|
+
f"{client} response ready for critique-card review."
|
|
110
|
+
if hook_kind == "stop"
|
|
111
|
+
else f"{client} subagent finished; signal recorded."
|
|
112
|
+
)
|
|
113
|
+
TraceLogger(root).log_event(
|
|
114
|
+
trace_type,
|
|
115
|
+
{"client": client.lower(), "signal": str(signal_path), "hook": hook_kind},
|
|
116
|
+
summary=summary,
|
|
117
|
+
)
|
|
118
|
+
except Exception as exc: # noqa: BLE001
|
|
119
|
+
print(f"DevCouncil stop hook signal error (ignored): {exc}", file=sys.stderr)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
from devcouncil.execution.stop_gate import evaluate_stop
|
|
123
|
+
|
|
124
|
+
result = evaluate_stop(root, payload)
|
|
125
|
+
_emit_stop_result(client, result)
|
|
126
|
+
except Exception as exc: # noqa: BLE001
|
|
127
|
+
print(f"DevCouncil stop hook gate error (fail-open): {exc}", file=sys.stderr)
|
|
128
|
+
if client.lower() == "gemini":
|
|
129
|
+
print(dump_json({"decision": "allow", "suppressOutput": True}, separators=(",", ":")))
|
|
130
|
+
|
|
131
|
+
|
|
39
132
|
def _emit_decision(client: str, action: str, reason: str) -> None:
|
|
40
133
|
if action == "deny":
|
|
41
134
|
print(reason, file=sys.stderr)
|
|
42
135
|
raise typer.Exit(code=2)
|
|
43
136
|
|
|
44
|
-
if client
|
|
137
|
+
if client == "codex":
|
|
138
|
+
# Codex PreToolUse currently accepts only systemMessage. Emitting the
|
|
139
|
+
# legacy decision/suppressOutput fields marks the hook failed and the tool
|
|
140
|
+
# proceeds, so successful checks stay silent and warnings use the one
|
|
141
|
+
# supported field.
|
|
142
|
+
if action == "warn":
|
|
143
|
+
print(dump_json({"systemMessage": f"DevCouncil Warning: {reason}"}, separators=(",", ":")))
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if client == "gemini":
|
|
45
147
|
payload = {"decision": "allow", "reason": reason, "suppressOutput": True}
|
|
46
148
|
if action == "warn":
|
|
47
149
|
payload["systemMessage"] = f"DevCouncil Warning: {reason}"
|
|
48
|
-
print(
|
|
150
|
+
print(dump_json(payload, separators=(",", ":")))
|
|
49
151
|
return
|
|
50
152
|
|
|
51
153
|
if action == "warn":
|
|
@@ -91,6 +193,7 @@ def pre_tool_use(
|
|
|
91
193
|
# A real tool call we cannot parse must not silently pass the gate.
|
|
92
194
|
return _emit_unevaluable(normalized_client, "Tool-call payload was not valid JSON; could not enforce policy.", strict)
|
|
93
195
|
root = _project_root(project_root)
|
|
196
|
+
log_step(f"hook/pre_tool_use: client={normalized_client}", project_root=root)
|
|
94
197
|
active_task = _active_task(root)
|
|
95
198
|
|
|
96
199
|
decision = HookPolicy(project_root=root).evaluate(call_data, active_task)
|
|
@@ -108,11 +211,360 @@ def post_tool_use(
|
|
|
108
211
|
):
|
|
109
212
|
"""
|
|
110
213
|
Coding CLI hook: Records a post-tool-use checkpoint for native hook clients.
|
|
214
|
+
|
|
215
|
+
Best-effort: when ``indexing.auto_refresh`` is enabled, incrementally refreshes
|
|
216
|
+
the repo map for files written by the tool call. Never blocks the agent on
|
|
217
|
+
refresh failure.
|
|
218
|
+
"""
|
|
219
|
+
payload_text = tool_call_json if tool_call_json is not None else sys.stdin.read()
|
|
220
|
+
root = _project_root(project_root)
|
|
221
|
+
log_step(f"hook/post_tool_use: client={client}", project_root=root)
|
|
222
|
+
try:
|
|
223
|
+
_maybe_refresh_map(root, payload_text)
|
|
224
|
+
except Exception as exc: # noqa: BLE001 — hooks must never break the session
|
|
225
|
+
print(f"DevCouncil map refresh error (ignored): {exc}", file=sys.stderr)
|
|
226
|
+
if client.lower() == "gemini":
|
|
227
|
+
print(dump_json({"decision": "allow", "suppressOutput": True}, separators=(",", ":")))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _extract_written_paths(payload: object) -> list[str]:
|
|
231
|
+
"""Best-effort path extraction from Write/Edit/MultiEdit-style hook payloads."""
|
|
232
|
+
paths: list[str] = []
|
|
233
|
+
if not isinstance(payload, dict):
|
|
234
|
+
return paths
|
|
235
|
+
|
|
236
|
+
def _add(value: object) -> None:
|
|
237
|
+
if isinstance(value, str) and value.strip():
|
|
238
|
+
paths.append(value.strip())
|
|
239
|
+
elif isinstance(value, list):
|
|
240
|
+
for item in value:
|
|
241
|
+
_add(item)
|
|
242
|
+
|
|
243
|
+
for key in (
|
|
244
|
+
"file_path",
|
|
245
|
+
"filePath",
|
|
246
|
+
"path",
|
|
247
|
+
"file",
|
|
248
|
+
"target_file",
|
|
249
|
+
"targetFile",
|
|
250
|
+
):
|
|
251
|
+
if key in payload:
|
|
252
|
+
_add(payload[key])
|
|
253
|
+
|
|
254
|
+
tool_input = payload.get("tool_input") or payload.get("toolInput") or payload.get("input")
|
|
255
|
+
if isinstance(tool_input, dict):
|
|
256
|
+
paths.extend(_extract_written_paths(tool_input))
|
|
257
|
+
elif isinstance(tool_input, str):
|
|
258
|
+
try:
|
|
259
|
+
parsed = json.loads(tool_input)
|
|
260
|
+
paths.extend(_extract_written_paths(parsed))
|
|
261
|
+
except json.JSONDecodeError:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
# Claude-style: tool_name + tool_input
|
|
265
|
+
tool_name = str(payload.get("tool_name") or payload.get("toolName") or payload.get("name") or "").lower()
|
|
266
|
+
if tool_name in {"write", "edit", "multiedit", "create", "notebookedit"} or "edit" in tool_name or "write" in tool_name:
|
|
267
|
+
for key in ("file_path", "filePath", "path", "target_file"):
|
|
268
|
+
if key in payload:
|
|
269
|
+
_add(payload[key])
|
|
270
|
+
|
|
271
|
+
edits = payload.get("edits")
|
|
272
|
+
if isinstance(edits, list):
|
|
273
|
+
for edit in edits:
|
|
274
|
+
paths.extend(_extract_written_paths(edit))
|
|
275
|
+
|
|
276
|
+
# Dedupe preserving order
|
|
277
|
+
seen: set[str] = set()
|
|
278
|
+
out: list[str] = []
|
|
279
|
+
for p in paths:
|
|
280
|
+
norm = p.replace("\\", "/")
|
|
281
|
+
if norm.startswith("./"):
|
|
282
|
+
norm = norm[2:]
|
|
283
|
+
if norm and norm not in seen:
|
|
284
|
+
seen.add(norm)
|
|
285
|
+
out.append(norm)
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# Debounce burst edits; reclaim a lock left by a crashed refresher after TTL.
|
|
290
|
+
MAP_REFRESH_DEBOUNCE_S = 0.3
|
|
291
|
+
MAP_REFRESH_LOCK_TTL_S = 120.0
|
|
292
|
+
_MAP_REFRESH_QUEUE_REL = Path(".devcouncil") / "cache" / "map_refresh_queue.json"
|
|
293
|
+
_MAP_REFRESH_LOCK_REL = Path(".devcouncil") / "cache" / "map_refresh.lock"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _pid_alive(pid: int) -> bool:
|
|
297
|
+
if pid <= 0:
|
|
298
|
+
return False
|
|
299
|
+
try:
|
|
300
|
+
os.kill(pid, 0)
|
|
301
|
+
except ProcessLookupError:
|
|
302
|
+
return False
|
|
303
|
+
except PermissionError:
|
|
304
|
+
# Process exists but we cannot signal it — treat as alive.
|
|
305
|
+
return True
|
|
306
|
+
except OSError:
|
|
307
|
+
return False
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _read_lock_meta(lock: Path) -> tuple[int | None, float | None]:
|
|
312
|
+
"""Return (pid, started_at) from lockfile contents, or (None, None) if unreadable."""
|
|
313
|
+
try:
|
|
314
|
+
raw = lock.read_text(encoding="utf-8").strip()
|
|
315
|
+
except OSError:
|
|
316
|
+
return None, None
|
|
317
|
+
if not raw:
|
|
318
|
+
return None, None
|
|
319
|
+
try:
|
|
320
|
+
data = json.loads(raw)
|
|
321
|
+
if isinstance(data, dict):
|
|
322
|
+
pid = data.get("pid")
|
|
323
|
+
started = data.get("started_at")
|
|
324
|
+
return (
|
|
325
|
+
int(pid) if pid is not None else None,
|
|
326
|
+
float(started) if started is not None else None,
|
|
327
|
+
)
|
|
328
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
329
|
+
pass
|
|
330
|
+
# Legacy / plain text: first line pid, optional second line timestamp
|
|
331
|
+
lines = raw.splitlines()
|
|
332
|
+
try:
|
|
333
|
+
pid = int(lines[0].strip())
|
|
334
|
+
except ValueError:
|
|
335
|
+
return None, None
|
|
336
|
+
started = None
|
|
337
|
+
if len(lines) > 1:
|
|
338
|
+
try:
|
|
339
|
+
started = float(lines[1].strip())
|
|
340
|
+
except ValueError:
|
|
341
|
+
started = None
|
|
342
|
+
return pid, started
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _lock_is_reclaimable(lock: Path, *, now: float | None = None) -> bool:
|
|
346
|
+
"""True when the lock owner is gone or the lock is older than the TTL."""
|
|
347
|
+
import time
|
|
348
|
+
|
|
349
|
+
if not lock.exists():
|
|
350
|
+
return True
|
|
351
|
+
pid, started = _read_lock_meta(lock)
|
|
352
|
+
if pid is not None and not _pid_alive(pid):
|
|
353
|
+
return True
|
|
354
|
+
ts = started
|
|
355
|
+
if ts is None:
|
|
356
|
+
try:
|
|
357
|
+
ts = lock.stat().st_mtime
|
|
358
|
+
except OSError:
|
|
359
|
+
return True
|
|
360
|
+
clock = now if now is not None else time.time()
|
|
361
|
+
return (clock - ts) > MAP_REFRESH_LOCK_TTL_S
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _try_acquire_refresh_lock(lock: Path) -> bool:
|
|
365
|
+
"""Create the lockfile exclusively, reclaiming a dead/stale owner first."""
|
|
366
|
+
import time
|
|
367
|
+
|
|
368
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
payload = dump_json({"pid": os.getpid(), "started_at": time.time()}, separators=(",", ":"))
|
|
370
|
+
try:
|
|
371
|
+
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
372
|
+
try:
|
|
373
|
+
os.write(fd, payload.encode("utf-8"))
|
|
374
|
+
finally:
|
|
375
|
+
os.close(fd)
|
|
376
|
+
return True
|
|
377
|
+
except FileExistsError:
|
|
378
|
+
if not _lock_is_reclaimable(lock):
|
|
379
|
+
return False
|
|
380
|
+
try:
|
|
381
|
+
lock.unlink(missing_ok=True)
|
|
382
|
+
except OSError:
|
|
383
|
+
return False
|
|
384
|
+
try:
|
|
385
|
+
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
386
|
+
try:
|
|
387
|
+
os.write(fd, payload.encode("utf-8"))
|
|
388
|
+
finally:
|
|
389
|
+
os.close(fd)
|
|
390
|
+
return True
|
|
391
|
+
except (FileExistsError, OSError):
|
|
392
|
+
return False
|
|
393
|
+
except OSError:
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _enqueue_refresh_paths(queue_path: Path, paths: list[str]) -> None:
|
|
398
|
+
"""Append paths to the durable refresh queue (never drop a pending refresh).
|
|
399
|
+
|
|
400
|
+
Uses temp+replace. Safe concurrent with rename-to-drain: if the holder
|
|
401
|
+
renames the queue away mid-read, we create a fresh queue the holder
|
|
402
|
+
re-checks after drain.
|
|
111
403
|
"""
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if
|
|
115
|
-
|
|
404
|
+
queue_path.parent.mkdir(parents=True, exist_ok=True)
|
|
405
|
+
existing: list[str] = []
|
|
406
|
+
if queue_path.is_file():
|
|
407
|
+
try:
|
|
408
|
+
data = json.loads(queue_path.read_text(encoding="utf-8"))
|
|
409
|
+
if isinstance(data, dict):
|
|
410
|
+
existing = [str(p) for p in (data.get("paths") or []) if p]
|
|
411
|
+
elif isinstance(data, list):
|
|
412
|
+
existing = [str(p) for p in data if p]
|
|
413
|
+
except (json.JSONDecodeError, OSError):
|
|
414
|
+
existing = []
|
|
415
|
+
seen = set(existing)
|
|
416
|
+
for p in paths:
|
|
417
|
+
if p not in seen:
|
|
418
|
+
existing.append(p)
|
|
419
|
+
seen.add(p)
|
|
420
|
+
tmp = queue_path.with_suffix(".tmp")
|
|
421
|
+
tmp.write_text(dump_json({"paths": existing}, indent=2) + "\n", encoding="utf-8")
|
|
422
|
+
tmp.replace(queue_path)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _parse_queue_file(path: Path) -> list[str]:
|
|
426
|
+
try:
|
|
427
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
428
|
+
if isinstance(data, dict):
|
|
429
|
+
return [str(p) for p in (data.get("paths") or []) if p]
|
|
430
|
+
if isinstance(data, list):
|
|
431
|
+
return [str(p) for p in data if p]
|
|
432
|
+
except (json.JSONDecodeError, OSError):
|
|
433
|
+
pass
|
|
434
|
+
return []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _take_queued_paths(queue_path: Path) -> list[str]:
|
|
438
|
+
"""Rename-to-drain the queue; merge any file recreated during drain.
|
|
439
|
+
|
|
440
|
+
Avoids the lose-on-drain race where unlink after read drops paths that
|
|
441
|
+
were enqueued between the read and the unlink.
|
|
442
|
+
"""
|
|
443
|
+
collected: list[str] = []
|
|
444
|
+
seen: set[str] = set()
|
|
445
|
+
|
|
446
|
+
def _absorb(paths: list[str]) -> None:
|
|
447
|
+
for p in paths:
|
|
448
|
+
if p not in seen:
|
|
449
|
+
seen.add(p)
|
|
450
|
+
collected.append(p)
|
|
451
|
+
|
|
452
|
+
# Bound retries against pathological enqueue churn during drain.
|
|
453
|
+
for _ in range(8):
|
|
454
|
+
if not queue_path.is_file():
|
|
455
|
+
break
|
|
456
|
+
drained = queue_path.with_name(queue_path.name + ".draining")
|
|
457
|
+
try:
|
|
458
|
+
os.replace(str(queue_path), str(drained))
|
|
459
|
+
except OSError:
|
|
460
|
+
break
|
|
461
|
+
_absorb(_parse_queue_file(drained))
|
|
462
|
+
try:
|
|
463
|
+
drained.unlink(missing_ok=True)
|
|
464
|
+
except OSError:
|
|
465
|
+
pass
|
|
466
|
+
return collected
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _maybe_refresh_map(root: Path, payload_text: str) -> None:
|
|
470
|
+
"""Config-gated, queue+drain map refresh with debounce and PID/TTL lock reclaim."""
|
|
471
|
+
import time
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
from devcouncil.app.config import load_config
|
|
475
|
+
|
|
476
|
+
cfg = load_config(root).indexing
|
|
477
|
+
if not getattr(cfg, "auto_refresh", True):
|
|
478
|
+
return
|
|
479
|
+
max_files = int(getattr(cfg, "auto_refresh_max_files", 40) or 40)
|
|
480
|
+
except Exception:
|
|
481
|
+
max_files = 40
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
payload = json.loads(payload_text) if payload_text.strip() else {}
|
|
485
|
+
except json.JSONDecodeError:
|
|
486
|
+
return
|
|
487
|
+
paths = _extract_written_paths(payload)
|
|
488
|
+
if not paths:
|
|
489
|
+
return
|
|
490
|
+
if len(paths) > max_files:
|
|
491
|
+
logger.debug(
|
|
492
|
+
"Skipping map auto-refresh: %d paths > max %d", len(paths), max_files
|
|
493
|
+
)
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
# Only refresh code-ish paths under the project
|
|
497
|
+
code_exts = {".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs"}
|
|
498
|
+
rels: list[str] = []
|
|
499
|
+
for p in paths:
|
|
500
|
+
candidate = Path(p)
|
|
501
|
+
if candidate.is_absolute():
|
|
502
|
+
try:
|
|
503
|
+
rel = candidate.relative_to(root).as_posix()
|
|
504
|
+
except ValueError:
|
|
505
|
+
continue
|
|
506
|
+
else:
|
|
507
|
+
rel = p.replace("\\", "/")
|
|
508
|
+
if rel.startswith("./"):
|
|
509
|
+
rel = rel[2:]
|
|
510
|
+
if Path(rel).suffix.lower() in code_exts:
|
|
511
|
+
rels.append(rel)
|
|
512
|
+
if not rels:
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
cache_dir = root / ".devcouncil" / "cache"
|
|
516
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
517
|
+
lock = root / _MAP_REFRESH_LOCK_REL
|
|
518
|
+
queue_path = root / _MAP_REFRESH_QUEUE_REL
|
|
519
|
+
|
|
520
|
+
if not _try_acquire_refresh_lock(lock):
|
|
521
|
+
# Another refresher holds the lock — enqueue so the holder drains us.
|
|
522
|
+
_enqueue_refresh_paths(queue_path, rels)
|
|
523
|
+
logger.debug("map refresh in progress; queued %d path(s)", len(rels))
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
# Debounce burst edits so a multi-file edit lands as one refresh.
|
|
528
|
+
time.sleep(MAP_REFRESH_DEBOUNCE_S)
|
|
529
|
+
pending = set(rels)
|
|
530
|
+
pending.update(_take_queued_paths(queue_path))
|
|
531
|
+
from devcouncil.indexing.graph.build import refresh_map_for_paths
|
|
532
|
+
|
|
533
|
+
while pending:
|
|
534
|
+
batch = sorted(pending)
|
|
535
|
+
pending.clear()
|
|
536
|
+
refresh_map_for_paths(root, batch)
|
|
537
|
+
log_step(
|
|
538
|
+
f"hook/post_tool_use: refreshed map for {len(batch)} path(s)",
|
|
539
|
+
project_root=root,
|
|
540
|
+
)
|
|
541
|
+
# Drain anything enqueued while we were refreshing.
|
|
542
|
+
pending.update(_take_queued_paths(queue_path))
|
|
543
|
+
except Exception:
|
|
544
|
+
logger.debug("incremental map refresh failed", exc_info=True)
|
|
545
|
+
finally:
|
|
546
|
+
try:
|
|
547
|
+
lock.unlink(missing_ok=True)
|
|
548
|
+
except OSError:
|
|
549
|
+
pass
|
|
550
|
+
# If anything arrived after we released but before unlink races settle,
|
|
551
|
+
# a subsequent PostToolUse will pick it up; also try a best-effort drain
|
|
552
|
+
# by re-acquiring if the queue is non-empty.
|
|
553
|
+
if queue_path.is_file() and _try_acquire_refresh_lock(lock):
|
|
554
|
+
try:
|
|
555
|
+
leftover = _take_queued_paths(queue_path)
|
|
556
|
+
if leftover:
|
|
557
|
+
from devcouncil.indexing.graph.build import refresh_map_for_paths
|
|
558
|
+
|
|
559
|
+
refresh_map_for_paths(root, leftover)
|
|
560
|
+
except Exception:
|
|
561
|
+
logger.debug("map refresh leftover drain failed", exc_info=True)
|
|
562
|
+
finally:
|
|
563
|
+
try:
|
|
564
|
+
lock.unlink(missing_ok=True)
|
|
565
|
+
except OSError:
|
|
566
|
+
pass
|
|
567
|
+
|
|
116
568
|
|
|
117
569
|
@app.command()
|
|
118
570
|
def agent_response(
|
|
@@ -121,26 +573,9 @@ def agent_response(
|
|
|
121
573
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
122
574
|
):
|
|
123
575
|
"""
|
|
124
|
-
Coding CLI hook:
|
|
576
|
+
Coding CLI Stop hook: live-review signal + unified stop gate (claims + verify).
|
|
125
577
|
"""
|
|
126
|
-
|
|
127
|
-
root = _project_root(project_root)
|
|
128
|
-
try:
|
|
129
|
-
payload = json.loads(payload_text) if payload_text.strip() else {}
|
|
130
|
-
except json.JSONDecodeError:
|
|
131
|
-
payload = {"raw": payload_text}
|
|
132
|
-
if isinstance(payload, dict) and not any(key in payload for key in ("task_id", "taskId", "task")):
|
|
133
|
-
active_id = active_task_id(root)
|
|
134
|
-
if active_id:
|
|
135
|
-
payload["task_id"] = active_id
|
|
136
|
-
signal_path = write_signal(root, client.lower(), payload)
|
|
137
|
-
TraceLogger(root).log_event(
|
|
138
|
-
"agent_response_ready",
|
|
139
|
-
{"client": client.lower(), "signal": str(signal_path)},
|
|
140
|
-
summary=f"{client} response ready for critique-card review.",
|
|
141
|
-
)
|
|
142
|
-
if client.lower() in {"codex", "gemini"}:
|
|
143
|
-
print(json.dumps({"decision": "allow", "suppressOutput": True}, separators=(",", ":")))
|
|
578
|
+
_handle_unified_stop(event_json, client=client, project_root=project_root, hook_kind="stop")
|
|
144
579
|
|
|
145
580
|
def _status_line(root: Path) -> str | None:
|
|
146
581
|
"""A one-line DevCouncil status snapshot, or None when uninitialized/unavailable.
|
|
@@ -160,11 +595,39 @@ def _status_line(root: Path) -> str | None:
|
|
|
160
595
|
summary = graph.coverage_summary()
|
|
161
596
|
state = StateRepository(session).get_state()
|
|
162
597
|
phase = compute_phase(graph, state.current_phase if state else None)
|
|
163
|
-
|
|
598
|
+
base = (
|
|
164
599
|
f"DevCouncil — phase: {phase}; tasks: {summary['total_tasks']}; "
|
|
165
600
|
f"gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking). "
|
|
166
601
|
"Use the devcouncil_* MCP tools and `dev` CLI to stay inside the verify loop."
|
|
167
602
|
)
|
|
603
|
+
hints: list[str] = []
|
|
604
|
+
try:
|
|
605
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
606
|
+
from devcouncil.utils.json_persist import read_json
|
|
607
|
+
|
|
608
|
+
map_path = root / ".devcouncil" / "repo_map.json"
|
|
609
|
+
if map_path.is_file():
|
|
610
|
+
loaded = read_json(map_path)
|
|
611
|
+
data = loaded if isinstance(loaded, dict) else {}
|
|
612
|
+
if RepoMapper(root).map_is_stale(data):
|
|
613
|
+
hints.append("repo map stale — run `dev map` (or MCP graph_ingest)")
|
|
614
|
+
else:
|
|
615
|
+
hints.append("no repo map — run `dev map`")
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
618
|
+
try:
|
|
619
|
+
from devcouncil.integrations.check import _cursor_config_status
|
|
620
|
+
|
|
621
|
+
status, fixable, _ = _cursor_config_status(root)
|
|
622
|
+
if status != "ok" and fixable:
|
|
623
|
+
hints.append(
|
|
624
|
+
f"Cursor MCP {status} — run `dev integrate cursor --apply`"
|
|
625
|
+
)
|
|
626
|
+
except Exception:
|
|
627
|
+
pass
|
|
628
|
+
if hints:
|
|
629
|
+
return f"{base} Continuity — {'; '.join(hints)}."
|
|
630
|
+
return base
|
|
168
631
|
except Exception:
|
|
169
632
|
return None
|
|
170
633
|
|
|
@@ -173,7 +636,7 @@ def _emit_additional_context(event_name: str, context: str | None) -> None:
|
|
|
173
636
|
"""Emit a Claude-Code hook result that injects additionalContext (exit 0)."""
|
|
174
637
|
if not context:
|
|
175
638
|
return
|
|
176
|
-
print(
|
|
639
|
+
print(dump_json({
|
|
177
640
|
"hookSpecificOutput": {
|
|
178
641
|
"hookEventName": event_name,
|
|
179
642
|
"additionalContext": context,
|
|
@@ -181,6 +644,23 @@ def _emit_additional_context(event_name: str, context: str | None) -> None:
|
|
|
181
644
|
}, separators=(",", ":")))
|
|
182
645
|
|
|
183
646
|
|
|
647
|
+
def _emit_system_message(event_name: str, message: str | None = None) -> None:
|
|
648
|
+
"""Emit a Claude-Code hook result with a user-visible systemMessage (exit 0)."""
|
|
649
|
+
if message is None:
|
|
650
|
+
message = event_name
|
|
651
|
+
event_name = ""
|
|
652
|
+
if not message:
|
|
653
|
+
return
|
|
654
|
+
import json
|
|
655
|
+
print(json.dumps({
|
|
656
|
+
"hookSpecificOutput": {
|
|
657
|
+
"hookEventName": event_name,
|
|
658
|
+
"systemMessage": message,
|
|
659
|
+
}
|
|
660
|
+
}, separators=(",", ":")))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
|
|
184
664
|
def _read_stdin_payload(event_json: str | None) -> dict:
|
|
185
665
|
text = event_json if event_json is not None else sys.stdin.read()
|
|
186
666
|
if not text or not text.strip():
|
|
@@ -199,13 +679,36 @@ def session_start(
|
|
|
199
679
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
200
680
|
):
|
|
201
681
|
"""Claude Code SessionStart hook: inject a DevCouncil status snapshot as context."""
|
|
202
|
-
_read_stdin_payload(event_json)
|
|
682
|
+
payload = _read_stdin_payload(event_json)
|
|
203
683
|
root = _project_root(project_root)
|
|
204
684
|
try:
|
|
205
|
-
|
|
685
|
+
# session_id lets trace consumers pair start/end events; ends are not
|
|
686
|
+
# guaranteed (Claude Code fires no SessionEnd on crash/kill — observed
|
|
687
|
+
# 34 starts vs 19 ends), so durations must treat unpaired starts as open.
|
|
688
|
+
details = {"client": client.lower(), "session_id": payload.get("session_id")}
|
|
689
|
+
TraceLogger(root).log_event("session_start", details, summary="Claude session started.")
|
|
690
|
+
except Exception as e:
|
|
691
|
+
logger.debug("Failed to record session_start trace event: %s", e)
|
|
692
|
+
_emit_additional_context("SessionStart", _session_start_context(root, payload))
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _session_start_context(root: Path, payload: dict) -> str | None:
|
|
696
|
+
if payload.get("source") == "compact":
|
|
697
|
+
from devcouncil.execution.stop_gate import compact_briefing, record_compact_brief
|
|
698
|
+
|
|
699
|
+
session_id = payload.get("session_id")
|
|
700
|
+
record_compact_brief(root, str(session_id) if session_id else None)
|
|
701
|
+
return compact_briefing(root, payload)
|
|
702
|
+
base = _status_line(root)
|
|
703
|
+
try:
|
|
704
|
+
from devcouncil.execution.stop_gate import session_briefing
|
|
705
|
+
|
|
706
|
+
extra = session_briefing(root, payload)
|
|
206
707
|
except Exception:
|
|
207
|
-
|
|
208
|
-
|
|
708
|
+
extra = None
|
|
709
|
+
if base and extra:
|
|
710
|
+
return f"{base}\n{extra}"
|
|
711
|
+
return base or extra
|
|
209
712
|
|
|
210
713
|
|
|
211
714
|
@app.command()
|
|
@@ -217,6 +720,15 @@ def user_prompt_submit(
|
|
|
217
720
|
"""Claude Code UserPromptSubmit hook: surface the current DevCouncil status as context."""
|
|
218
721
|
_read_stdin_payload(event_json)
|
|
219
722
|
root = _project_root(project_root)
|
|
723
|
+
try:
|
|
724
|
+
from devcouncil.app.config import load_config
|
|
725
|
+
from devcouncil.execution.stop_gate import recent_compact_brief
|
|
726
|
+
|
|
727
|
+
skip_secs = load_config(root).execution.skip_prompt_status_after_compact_seconds
|
|
728
|
+
if recent_compact_brief(root, skip_secs):
|
|
729
|
+
return
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
220
732
|
_emit_additional_context("UserPromptSubmit", _status_line(root))
|
|
221
733
|
|
|
222
734
|
|
|
@@ -227,12 +739,17 @@ def session_end(
|
|
|
227
739
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
228
740
|
):
|
|
229
741
|
"""Claude Code SessionEnd hook: record session teardown in the DevCouncil trace."""
|
|
230
|
-
_read_stdin_payload(event_json)
|
|
742
|
+
payload = _read_stdin_payload(event_json)
|
|
231
743
|
root = _project_root(project_root)
|
|
232
744
|
try:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
745
|
+
details = {
|
|
746
|
+
"client": client.lower(),
|
|
747
|
+
"session_id": payload.get("session_id"),
|
|
748
|
+
"reason": payload.get("reason"),
|
|
749
|
+
}
|
|
750
|
+
TraceLogger(root).log_event("session_end", details, summary="Claude session ended.")
|
|
751
|
+
except Exception as e:
|
|
752
|
+
logger.debug("Failed to record session_end trace event: %s", e)
|
|
236
753
|
|
|
237
754
|
|
|
238
755
|
@app.command()
|
|
@@ -241,37 +758,59 @@ def pre_compact(
|
|
|
241
758
|
client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
|
|
242
759
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
243
760
|
):
|
|
244
|
-
"""Claude Code PreCompact hook:
|
|
245
|
-
_read_stdin_payload(event_json)
|
|
761
|
+
"""Claude Code PreCompact hook: write continuity snapshot + trace (no model context)."""
|
|
762
|
+
payload = _read_stdin_payload(event_json)
|
|
246
763
|
root = _project_root(project_root)
|
|
247
764
|
try:
|
|
248
|
-
|
|
765
|
+
from devcouncil.execution.stop_gate import write_compact_snapshot
|
|
766
|
+
|
|
767
|
+
write_compact_snapshot(root, payload)
|
|
768
|
+
except Exception as e:
|
|
769
|
+
logger.debug("Failed to write compact snapshot: %s", e)
|
|
770
|
+
try:
|
|
771
|
+
TraceLogger(root).log_event(
|
|
772
|
+
"pre_compact",
|
|
773
|
+
{"client": client.lower(), "session_id": payload.get("session_id")},
|
|
774
|
+
summary="Claude context compaction starting.",
|
|
775
|
+
)
|
|
776
|
+
except Exception as e:
|
|
777
|
+
logger.debug("Failed to record pre_compact trace event: %s", e)
|
|
778
|
+
try:
|
|
779
|
+
from devcouncil.app.config import load_config
|
|
780
|
+
|
|
781
|
+
if load_config(root).execution.compact_snapshot_toast:
|
|
782
|
+
_emit_system_message(
|
|
783
|
+
"PreCompact",
|
|
784
|
+
"DevCouncil saved a compact snapshot (phase, task, gaps) before context compaction.",
|
|
785
|
+
)
|
|
249
786
|
except Exception:
|
|
250
787
|
pass
|
|
251
788
|
|
|
252
789
|
|
|
253
790
|
@app.command()
|
|
254
|
-
def
|
|
791
|
+
def post_compact(
|
|
255
792
|
event_json: str | None = typer.Argument(None, help="The JSON hook payload from Claude Code."),
|
|
256
793
|
client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
|
|
257
794
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
258
795
|
):
|
|
259
|
-
"""Claude Code
|
|
796
|
+
"""Claude Code PostCompact hook: trace only — never injects additionalContext."""
|
|
260
797
|
payload = _read_stdin_payload(event_json)
|
|
261
798
|
root = _project_root(project_root)
|
|
262
|
-
if not any(key in payload for key in ("task_id", "taskId", "task")):
|
|
263
|
-
active_id = active_task_id(root)
|
|
264
|
-
if active_id:
|
|
265
|
-
payload["task_id"] = active_id
|
|
266
799
|
try:
|
|
267
|
-
|
|
268
|
-
TraceLogger(root).log_event(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
800
|
+
details = {"client": client.lower(), "session_id": payload.get("session_id")}
|
|
801
|
+
TraceLogger(root).log_event("post_compact", details, summary="Claude context compaction finished.")
|
|
802
|
+
except Exception as e:
|
|
803
|
+
logger.debug("Failed to record post_compact trace event: %s", e)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@app.command()
|
|
807
|
+
def subagent_stop(
|
|
808
|
+
event_json: str | None = typer.Argument(None, help="The JSON hook payload from Claude Code."),
|
|
809
|
+
client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
|
|
810
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
811
|
+
):
|
|
812
|
+
"""Claude Code SubagentStop hook: same unified stop gate as Stop."""
|
|
813
|
+
_handle_unified_stop(event_json, client=client, project_root=project_root, hook_kind="subagent")
|
|
275
814
|
|
|
276
815
|
|
|
277
816
|
@app.command()
|
|
@@ -284,13 +823,17 @@ def notification(
|
|
|
284
823
|
payload = _read_stdin_payload(event_json)
|
|
285
824
|
root = _project_root(project_root)
|
|
286
825
|
try:
|
|
826
|
+
message = str(payload.get("message", ""))[:200]
|
|
287
827
|
TraceLogger(root).log_event(
|
|
288
828
|
"claude_notification",
|
|
289
|
-
{"client": client.lower(), "message":
|
|
290
|
-
summary
|
|
829
|
+
{"client": client.lower(), "message": message},
|
|
830
|
+
# Put the message in the summary too — that's the field trace
|
|
831
|
+
# viewers render, and 18 consecutive "Claude notification." lines
|
|
832
|
+
# tell a reader nothing.
|
|
833
|
+
summary=f"Claude notification: {message}" if message else "Claude notification.",
|
|
291
834
|
)
|
|
292
|
-
except Exception:
|
|
293
|
-
|
|
835
|
+
except Exception as e:
|
|
836
|
+
logger.debug("Failed to record claude_notification trace event: %s", e)
|
|
294
837
|
|
|
295
838
|
|
|
296
839
|
@app.command()
|
|
@@ -306,47 +849,49 @@ def claude_statusline(
|
|
|
306
849
|
# Prefer the cwd Claude reports so the line reflects the active workspace.
|
|
307
850
|
cwd = payload.get("cwd") if isinstance(payload, dict) else None
|
|
308
851
|
root = _project_root(Path(cwd) if isinstance(cwd, str) and cwd else project_root)
|
|
309
|
-
|
|
310
|
-
|
|
852
|
+
session_id = payload.get("session_id") if isinstance(payload, dict) else None
|
|
853
|
+
line1 = _status_line(root)
|
|
854
|
+
if not line1:
|
|
311
855
|
print("DevCouncil: not initialized")
|
|
312
856
|
return
|
|
313
|
-
|
|
314
|
-
|
|
857
|
+
line1 = line1.split(". Use the")[0]
|
|
858
|
+
try:
|
|
859
|
+
from devcouncil.execution.stop_gate import statusline_tally
|
|
860
|
+
|
|
861
|
+
tally = statusline_tally(root, str(session_id) if session_id else None)
|
|
862
|
+
except Exception:
|
|
863
|
+
tally = None
|
|
864
|
+
model = payload.get("model") if isinstance(payload, dict) else None
|
|
865
|
+
extras: list[str] = []
|
|
866
|
+
if tally:
|
|
867
|
+
extras.append(tally)
|
|
868
|
+
if isinstance(model, str) and model.strip():
|
|
869
|
+
extras.append(model.strip())
|
|
870
|
+
if extras:
|
|
871
|
+
print(line1)
|
|
872
|
+
print(" | ".join(extras))
|
|
873
|
+
else:
|
|
874
|
+
print(line1)
|
|
315
875
|
|
|
316
876
|
|
|
317
877
|
@app.command()
|
|
318
878
|
def post_task(
|
|
879
|
+
event_json: str | None = typer.Argument(None, help="Optional JSON hook payload."),
|
|
319
880
|
client: str = typer.Option("claude", "--client", help="Hook client: claude, codex, gemini, cursor, or generic."),
|
|
320
881
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
321
882
|
):
|
|
322
883
|
"""
|
|
323
|
-
|
|
884
|
+
Deprecated alias for the unified Stop handler (``execution.stop_gate``).
|
|
324
885
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
user to run ``dev verify`` (the default, to keep hooks fast/cheap).
|
|
886
|
+
``execution.verify_on_post_task`` is a deprecated alias for
|
|
887
|
+
``stop_gate.mode != off`` with ``verify_active_task``.
|
|
328
888
|
"""
|
|
329
|
-
|
|
330
|
-
try:
|
|
331
|
-
from devcouncil.app.config import load_config
|
|
332
|
-
verify_enabled = load_config(root).execution.verify_on_post_task
|
|
333
|
-
except Exception:
|
|
334
|
-
verify_enabled = False
|
|
335
|
-
|
|
336
|
-
if not verify_enabled:
|
|
337
|
-
console.print("[cyan]DevCouncil: coding agent finished task.[/cyan]")
|
|
338
|
-
console.print("Run [bold]dev verify[/bold] to finalize implementation evidence.")
|
|
339
|
-
_emit_post_task_allow(client)
|
|
340
|
-
return
|
|
341
|
-
|
|
342
|
-
summary = _verify_active_task(root)
|
|
343
|
-
console.print(summary)
|
|
344
|
-
_emit_post_task_allow(client)
|
|
889
|
+
_handle_unified_stop(event_json, client=client, project_root=project_root, hook_kind="post_task")
|
|
345
890
|
|
|
346
891
|
|
|
347
892
|
def _emit_post_task_allow(client: str) -> None:
|
|
348
|
-
if client.lower()
|
|
349
|
-
print(
|
|
893
|
+
if client.lower() == "gemini":
|
|
894
|
+
print(dump_json({"decision": "allow", "suppressOutput": True}, separators=(",", ":")))
|
|
350
895
|
|
|
351
896
|
|
|
352
897
|
def _verify_active_task(root: Path) -> str:
|