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.
Files changed (365) hide show
  1. package/README.md +46 -30
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +49 -30
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +960 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +303 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/tasks.py +12 -3
  255. package/src/devcouncil/live/transcripts.py +69 -2
  256. package/src/devcouncil/llm/cache.py +5 -6
  257. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  258. package/src/devcouncil/llm/provider.py +647 -73
  259. package/src/devcouncil/llm/router.py +271 -46
  260. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  261. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  262. package/src/devcouncil/optimization/skillopt.py +9 -5
  263. package/src/devcouncil/planning/arbiter_service.py +12 -3
  264. package/src/devcouncil/planning/correction_manifest.py +107 -10
  265. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  266. package/src/devcouncil/planning/plan_service.py +5 -2
  267. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  268. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  269. package/src/devcouncil/planning/question_conversion.py +56 -0
  270. package/src/devcouncil/planning/spec_service.py +9 -3
  271. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  272. package/src/devcouncil/repo/gitignore.py +1 -2
  273. package/src/devcouncil/reporting/evidence_export.py +124 -0
  274. package/src/devcouncil/reporting/evidence_html.py +210 -0
  275. package/src/devcouncil/reporting/json_report.py +16 -12
  276. package/src/devcouncil/reporting/markdown_report.py +38 -9
  277. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  278. package/src/devcouncil/reporting/report_builder.py +40 -4
  279. package/src/devcouncil/reporting/task_provenance.py +42 -0
  280. package/src/devcouncil/reporting/verdict.py +75 -0
  281. package/src/devcouncil/skills/library/README.md +1 -0
  282. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  283. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  285. package/src/devcouncil/skills/registry.py +43 -12
  286. package/src/devcouncil/storage/db.py +57 -11
  287. package/src/devcouncil/storage/models.py +6 -0
  288. package/src/devcouncil/storage/native.py +5 -3
  289. package/src/devcouncil/storage/repositories.py +50 -18
  290. package/src/devcouncil/telemetry/context.py +28 -0
  291. package/src/devcouncil/telemetry/cost.py +4 -5
  292. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  293. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  294. package/src/devcouncil/telemetry/stages.py +27 -2
  295. package/src/devcouncil/telemetry/tracker.py +50 -13
  296. package/src/devcouncil/ui/dashboard.py +120 -8
  297. package/src/devcouncil/utils/fsio.py +58 -0
  298. package/src/devcouncil/utils/git_snapshot.py +112 -0
  299. package/src/devcouncil/utils/json_persist.py +53 -0
  300. package/src/devcouncil/utils/proc.py +89 -0
  301. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  302. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  303. package/src/devcouncil/verification/checks/__init__.py +41 -0
  304. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  305. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  306. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  307. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  308. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  309. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  310. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  311. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  312. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  313. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  314. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  315. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  316. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  317. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  318. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  319. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  320. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  321. package/src/devcouncil/verification/checks/wiring.py +216 -0
  322. package/src/devcouncil/verification/claims/__init__.py +23 -0
  323. package/src/devcouncil/verification/claims/checks.py +395 -0
  324. package/src/devcouncil/verification/claims/mapper.py +168 -0
  325. package/src/devcouncil/verification/claims/models.py +39 -0
  326. package/src/devcouncil/verification/claims/transcript.py +92 -0
  327. package/src/devcouncil/verification/claims/verdict.py +88 -0
  328. package/src/devcouncil/verification/command_evidence.py +170 -0
  329. package/src/devcouncil/verification/command_malformation.py +147 -0
  330. package/src/devcouncil/verification/command_runner.py +164 -0
  331. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  332. package/src/devcouncil/verification/diff_coverage.py +151 -0
  333. package/src/devcouncil/verification/difficulty.py +296 -0
  334. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  335. package/src/devcouncil/verification/gap_ids.py +63 -0
  336. package/src/devcouncil/verification/gate_cache.py +194 -0
  337. package/src/devcouncil/verification/gate_selector.py +344 -0
  338. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  339. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  340. package/src/devcouncil/verification/incremental_check.py +241 -0
  341. package/src/devcouncil/verification/next_actions.py +60 -1
  342. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  343. package/src/devcouncil/verification/sandbox.py +38 -11
  344. package/src/devcouncil/verification/stub_detector.py +369 -0
  345. package/src/devcouncil/verification/test_resolver.py +67 -1
  346. package/src/devcouncil/verification/verifier.py +137 -1666
  347. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  348. package/src/devcouncil/verification/verify_setup.py +176 -0
  349. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  350. package/src/semantic_layer/__init__.py +58 -0
  351. package/src/semantic_layer/benchmark.py +75 -0
  352. package/src/semantic_layer/cache.py +290 -0
  353. package/src/semantic_layer/compressor.py +137 -0
  354. package/src/semantic_layer/config.py +75 -0
  355. package/src/semantic_layer/embeddings.py +69 -0
  356. package/src/semantic_layer/llm_backends.py +99 -0
  357. package/src/semantic_layer/pipeline.py +111 -0
  358. package/src/semantic_layer/router.py +128 -0
  359. package/src/semantic_layer/tuner.py +72 -0
  360. package/uv.lock +973 -9
  361. package/src/devcouncil/artifacts/migrations.py +0 -20
  362. package/src/devcouncil/artifacts/schemas.py +0 -23
  363. package/src/devcouncil/artifacts/serializer.py +0 -21
  364. package/src/devcouncil/integrations/gitnexus.py +0 -70
  365. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -1,59 +1,93 @@
1
1
  import asyncio
2
- import hashlib
3
- import json
4
2
  import logging
5
3
  import os
6
- import subprocess
7
- import sys
4
+ from contextlib import asynccontextmanager
8
5
  from pathlib import Path
6
+
9
7
  from mcp.server import Server
10
- from typing import Any, NamedTuple
11
8
  from mcp.server.stdio import stdio_server
12
- from mcp.types import (
13
- Tool,
14
- TextContent,
15
- Resource,
16
- Prompt,
17
- PromptArgument,
18
- PromptMessage,
19
- GetPromptResult,
20
- )
9
+ from mcp.types import TextContent
21
10
  from pydantic import AnyUrl
22
- from devcouncil.storage.db import get_db
23
- from devcouncil.storage.repositories import (
24
- TaskRepository,
25
- ArtifactGraphRepository,
26
- StateRepository,
27
- RequirementRepository,
28
- EvidenceRepository,
29
- GapRepository,
30
- )
31
- from devcouncil.storage.native import (
32
- TaskLeaseRepository,
33
- ShellCommandRepository,
34
- FileChangeRepository,
35
- VerificationRunRepository,
36
- CorrectionManifestRepository,
37
- )
38
- from devcouncil.domain.evidence import CommandResult, DiffEvidence, DiffCoverageEvidence, TestEvidence
39
- from devcouncil.verification.next_actions import split_next_actions
40
- from devcouncil.reporting.report_builder import ReportBuilder
41
- from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
42
- from devcouncil.execution.hook_policy import HookPolicy
43
- from devcouncil.execution.prompt_builder import PromptBuilder
44
- from devcouncil.utils.subprocess_env import clean_subprocess_env
45
- from devcouncil.telemetry.traces import read_trace_events
46
- from devcouncil.indexing.ast_matcher import AstMatcher
47
- from devcouncil.indexing.lsp import LspInspector
48
- from devcouncil.app.project_status import compute_phase
49
- from devcouncil.live.cards import filter_cards, get_card, load_cards
50
- from devcouncil.live.repair_prompt import build_bulk_live_repair_prompt, build_live_repair_prompt
11
+
51
12
  from devcouncil.integrations.check import integration_status_summary
52
- from devcouncil.live.summary import live_review_summary
13
+ from devcouncil.integrations.mcp.handlers import ast_lsp as ast_lsp_handlers
14
+ from devcouncil.integrations.mcp.handlers import checkout as checkout_handlers
15
+ from devcouncil.integrations.mcp.handlers import codeintel as codeintel_handlers
16
+ from devcouncil.integrations.mcp.handlers import debug as debug_handlers
17
+ from devcouncil.integrations.mcp.handlers import cli_gate as cli_gate_handlers
18
+ from devcouncil.integrations.mcp.handlers import evidence as evidence_handlers
19
+ from devcouncil.integrations.mcp.handlers import git as git_handlers
20
+ from devcouncil.integrations.mcp.handlers import graph as graph_handlers
21
+ from devcouncil.integrations.mcp.handlers import handoff as handoff_handlers
22
+ from devcouncil.integrations.mcp.handlers import knowledge as knowledge_handlers
23
+ from devcouncil.integrations.mcp.handlers import live as live_handlers
24
+ from devcouncil.integrations.mcp.handlers import map as map_handlers
25
+ from devcouncil.integrations.mcp.handlers import next_task as next_task_handlers
26
+ from devcouncil.integrations.mcp.handlers import policy as policy_handlers
27
+ from devcouncil.integrations.mcp.handlers import prompts as prompt_handlers
28
+ from devcouncil.integrations.mcp.handlers import provenance as provenance_handlers
29
+ from devcouncil.integrations.mcp.handlers import read as read_handlers
30
+ from devcouncil.integrations.mcp.handlers import router_cache
31
+ from devcouncil.integrations.mcp.handlers import run as run_handlers
32
+ from devcouncil.integrations.mcp.handlers import runs as runs_handlers
33
+ from devcouncil.integrations.mcp.handlers import scope as scope_handlers
34
+ from devcouncil.integrations.mcp.handlers import status as status_handlers
35
+ from devcouncil.integrations.mcp.handlers import task as task_handlers
36
+ from devcouncil.integrations.mcp.handlers import tool_specs
37
+ from devcouncil.integrations.mcp.handlers import trace as trace_handlers
38
+ from devcouncil.integrations.mcp.handlers import verify as verify_handlers
39
+ from devcouncil.integrations.mcp.handlers import wiki as wiki_handlers
40
+ from devcouncil.integrations.mcp.handlers import write as write_handlers
41
+ from devcouncil.integrations.mcp.handlers import lease as lease_handlers
42
+ from devcouncil.integrations.mcp.util import (
43
+ error_text as _error_text,
44
+ json_text as _json_text,
45
+ normalize_arguments as _normalize_arguments,
46
+ )
47
+ from devcouncil.integrations.mcp import util as _mcp_util
48
+ from devcouncil.storage.db import get_db
49
+ from devcouncil.telemetry.stages import log_step
50
+
51
+ # Re-exported for tests and backward compatibility.
52
+ _CLI_OUTPUT_LIMIT = _mcp_util._CLI_OUTPUT_LIMIT
53
+ _CLI_TIMEOUT_SECONDS = _mcp_util._CLI_TIMEOUT_SECONDS
54
+ _allowed_next_tools = _mcp_util.allowed_next_tools
53
55
 
54
56
  logger = logging.getLogger(__name__)
55
57
 
56
- app = Server("devcouncil")
58
+
59
+ @asynccontextmanager
60
+ async def _lifespan(_server): # noqa: ANN001
61
+ """Keep one native watcher alive for the MCP process lifecycle."""
62
+ coordinator = None
63
+ root = _project_root().expanduser().resolve()
64
+ try:
65
+ from devcouncil.app.config import load_config
66
+ from devcouncil.codeintel import get_codeintel_service
67
+ from devcouncil.codeintel.sync import get_sync_coordinator
68
+
69
+ config = load_config(root).code_intelligence
70
+ service = get_codeintel_service(root)
71
+ graph_export = root / ".devcouncil" / "graph" / "code_graph.json"
72
+ if config.enabled and config.auto_sync and (service.store.exists() or graph_export.is_file()):
73
+ coordinator = get_sync_coordinator(
74
+ root,
75
+ debounce_seconds=config.debounce_ms / 1000.0,
76
+ reconcile_seconds=float(config.reconcile_seconds),
77
+ allow_polling_fallback=config.allow_polling_fallback,
78
+ )
79
+ coordinator.start()
80
+ except Exception:
81
+ logger.warning("MCP code-intelligence watcher did not start", exc_info=True)
82
+ try:
83
+ yield {"codeintel": coordinator}
84
+ finally:
85
+ if coordinator is not None:
86
+ coordinator.stop()
87
+
88
+
89
+ app = Server("devcouncil", lifespan=_lifespan)
90
+
57
91
  _DB_REQUIRED_TOOLS = {
58
92
  "devcouncil_status",
59
93
  "devcouncil_report",
@@ -82,2430 +116,270 @@ _DB_REQUIRED_TOOLS = {
82
116
  "devcouncil_run_command",
83
117
  "devcouncil_next_task",
84
118
  }
85
- _CLI_ALLOWED_ROOTS = {"status", "tasks", "report", "map", "prompt", "show", "trace", "lsp", "ast", "verify"}
86
- _CLI_FORBIDDEN_FLAGS = {"--project-root", "--github", "--github-pr-comment", "--gitlab-pr-comment"}
87
- _CLI_TIMEOUT_SECONDS = 120
88
- _CLI_OUTPUT_LIMIT = 20_000
89
- # Allowed values for devcouncil_record_command.status (validated, not free-text).
90
- _RECORD_COMMAND_STATUSES = {"started", "finished", "failed", "blocked"}
91
-
92
119
 
93
- def _forbidden_cli_flags(args: list[str]) -> list[str]:
94
- forbidden: set[str] = set()
95
- for arg in args:
96
- for flag in _CLI_FORBIDDEN_FLAGS:
97
- if arg == flag or arg.startswith(f"{flag}="):
98
- forbidden.add(flag)
99
- return sorted(forbidden)
100
120
 
101
-
102
- def _truncate_text(value: str | bytes | None, limit: int = _CLI_OUTPUT_LIMIT) -> tuple[str, bool]:
103
- if value is None:
104
- return "", False
105
- if isinstance(value, bytes):
106
- value = value.decode("utf-8", errors="replace")
107
- if len(value) <= limit:
108
- return value, False
109
- marker = f"\n...[truncated to {limit} characters]"
110
- return value[:limit] + marker, True
121
+ def _reset_caches() -> None:
122
+ """Drop all per-root MCP caches. Re-exported for test isolation."""
123
+ router_cache.reset_caches()
111
124
 
112
125
 
113
- def _json_text(payload: dict[str, object]) -> list[TextContent]:
114
- return [TextContent(type="text", text=json.dumps(payload, indent=2))]
126
+ def _project_root() -> Path:
127
+ configured = os.environ.get("DEVCOUNCIL_PROJECT_ROOT")
128
+ return Path(configured).expanduser().resolve() if configured else Path(".")
115
129
 
116
130
 
117
- def _is_git_repo(root: Path) -> bool:
118
- try:
119
- result = subprocess.run(
120
- ["git", "rev-parse", "--is-inside-work-tree"],
121
- cwd=root, capture_output=True, text=True,
122
- )
123
- return result.returncode == 0 and result.stdout.strip() == "true"
124
- except Exception:
125
- return False
131
+ @app.list_tools()
132
+ async def list_tools():
133
+ return tool_specs.all_tools()
126
134
 
127
135
 
128
- def _read_log_file(path: str | None) -> str:
129
- """Best-effort read of a persisted stdout/stderr log; tolerate a missing file."""
130
- if not path:
131
- return ""
132
- try:
133
- return Path(path).read_text(encoding="utf-8", errors="replace")
134
- except OSError:
135
- return ""
136
-
137
-
138
- async def _git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, object]:
139
- """Compute a (optionally path-scoped, optionally staged) git diff.
140
-
141
- Returns {ok, files:[{path,status,additions,deletions}], unified_diff (truncated),
142
- truncated}. Never raises — a git failure is reported as an empty diff with the
143
- stderr in an error field so the agent can act on it."""
144
- diff_args = ["git", "diff"]
145
- numstat_args = ["git", "diff", "--numstat"]
146
- namestatus_args = ["git", "diff", "--name-status"]
147
- if staged:
148
- for args in (diff_args, numstat_args, namestatus_args):
149
- args.append("--cached")
150
- if paths:
151
- for args in (diff_args, numstat_args, namestatus_args):
152
- args.append("--")
153
- args.extend(paths)
154
-
155
- def _run(args: list[str]) -> subprocess.CompletedProcess[str]:
156
- return subprocess.run(
157
- args, cwd=root, capture_output=True, text=True,
158
- encoding="utf-8", errors="replace", timeout=_CLI_TIMEOUT_SECONDS,
159
- )
136
+ @app.list_resources()
137
+ async def list_resources():
138
+ return await provenance_handlers.list_resources(_project_root())
160
139
 
161
- try:
162
- loop = asyncio.get_event_loop()
163
- diff_proc, numstat_proc, namestatus_proc = await asyncio.gather(
164
- loop.run_in_executor(None, _run, diff_args),
165
- loop.run_in_executor(None, _run, numstat_args),
166
- loop.run_in_executor(None, _run, namestatus_args),
167
- )
168
- except (OSError, subprocess.TimeoutExpired) as exc:
169
- return {"ok": False, "files": [], "unified_diff": "", "truncated": False, "error": str(exc)}
170
-
171
- status_by_path: dict[str, str] = {}
172
- for line in namestatus_proc.stdout.splitlines():
173
- parts = line.split("\t")
174
- if len(parts) >= 2:
175
- status_by_path[parts[-1].replace("\\", "/")] = parts[0]
176
-
177
- files: list[dict[str, object]] = []
178
- for line in numstat_proc.stdout.splitlines():
179
- parts = line.split("\t")
180
- if len(parts) < 3:
181
- continue
182
- added_str, deleted_str, file_path = parts[0], parts[1], parts[-1]
183
- file_path = file_path.replace("\\", "/")
184
- files.append({
185
- "path": file_path,
186
- "status": status_by_path.get(file_path, "M"),
187
- "additions": int(added_str) if added_str.isdigit() else 0,
188
- "deletions": int(deleted_str) if deleted_str.isdigit() else 0,
189
- })
190
-
191
- unified_diff, truncated = _truncate_text(diff_proc.stdout)
192
- return {"ok": True, "files": files, "unified_diff": unified_diff, "truncated": truncated, "staged": staged}
193
-
194
-
195
- def _within_root(root: Path, rel_or_abs: str) -> Path | None:
196
- """Resolve a path against the project root and confirm it stays inside it.
197
-
198
- Returns the absolute resolved path, or None when the path escapes the project
199
- (a containment violation — a write tool must refuse it)."""
200
- raw = rel_or_abs.strip().strip('"').replace("\\", "/")
201
- try:
202
- candidate = Path(raw)
203
- resolved = candidate.resolve() if candidate.is_absolute() else (root / raw).resolve()
204
- resolved.relative_to(root.resolve())
205
- return resolved
206
- except (OSError, ValueError):
207
- return None
208
-
209
-
210
- def _diff_target_paths(unified_diff: str) -> list[str]:
211
- """Extract EVERY repo-relative file a unified diff touches — both sides.
212
-
213
- Captures pre- and post-image paths from ``---``/``+++`` hunk headers AND the
214
- ``rename from/to`` / ``copy from/to`` lines (a pure rename has no hunk headers, so
215
- its source would otherwise escape the policy check — letting a protected file be
216
- moved out of scope). Handles paths with spaces and git's C-quoting. Every target is
217
- then policy-checked before the patch is applied."""
218
- targets: list[str] = []
219
- seen: set[str] = set()
220
-
221
- def _clean(token: str) -> str | None:
222
- token = token.strip()
223
- if len(token) >= 2 and token.startswith('"') and token.endswith('"'):
224
- try:
225
- token = token[1:-1].encode("utf-8").decode("unicode_escape")
226
- except Exception:
227
- token = token[1:-1]
228
- if not token or token == "/dev/null":
229
- return None
230
- if token[:2] in ("a/", "b/"):
231
- token = token[2:]
232
- return token or None
233
-
234
- def _add(path: str | None) -> None:
235
- if path and path not in seen:
236
- seen.add(path)
237
- targets.append(path)
238
-
239
- for line in unified_diff.splitlines():
240
- if line.startswith("--- ") or line.startswith("+++ "):
241
- _add(_clean(line[4:]))
242
- elif line.startswith(("rename from ", "rename to ", "copy from ", "copy to ")):
243
- _add(_clean(line.split(" ", 2)[2]))
244
- elif line.startswith("diff --git "):
245
- # Fallback for metadata-only changes (e.g. mode change) that have no hunk or
246
- # rename lines; best-effort split handles the common no-space case.
247
- parts = line[len("diff --git "):].split()
248
- if len(parts) == 2:
249
- _add(_clean(parts[0]))
250
- _add(_clean(parts[1]))
251
- return targets
252
-
253
-
254
- def _lease_ttl_seconds(root: Path) -> int:
255
- """Default MCP lease TTL from config (so a crashed agent's lease auto-expires)."""
256
- try:
257
- from devcouncil.app.config import load_config
258
140
 
259
- return max(0, int(load_config(root).execution.lease_ttl_seconds))
260
- except Exception:
261
- return 1800
141
+ @app.read_resource()
142
+ async def read_resource(uri: AnyUrl) -> str:
143
+ return await provenance_handlers.read_resource(_project_root(), uri)
262
144
 
263
145
 
264
- # Per-resolved-root caches for stateless, construction-heavy objects that several
265
- # MCP handlers would otherwise rebuild on every request. Keyed by str(root.resolve())
266
- # so distinct project roots (and distinct test temp dirs) never collide. _reset_caches()
267
- # clears them for tests that need a clean slate within a single process.
268
- _ROUTER_CACHE: dict[str, tuple[Any, Any]] = {}
269
- _AST_MATCHER_CACHE: dict[str, AstMatcher] = {}
270
- _LSP_INSPECTOR_CACHE: dict[str, LspInspector] = {}
271
- _GRAPH_ADAPTER_CACHE: dict[str, CodeReviewGraphAdapter] = {}
146
+ @app.list_prompts()
147
+ async def list_prompts():
148
+ return prompt_handlers.list_prompts()
272
149
 
273
150
 
274
- def _reset_caches() -> None:
275
- """Drop all per-root MCP caches (router/ast/lsp/graph). For test isolation."""
276
- _ROUTER_CACHE.clear()
277
- _AST_MATCHER_CACHE.clear()
278
- _LSP_INSPECTOR_CACHE.clear()
279
- _GRAPH_ADAPTER_CACHE.clear()
280
-
281
-
282
- def _get_ast_matcher(root: Path) -> AstMatcher:
283
- key = str(root.resolve())
284
- matcher = _AST_MATCHER_CACHE.get(key)
285
- if matcher is None:
286
- matcher = AstMatcher(root)
287
- _AST_MATCHER_CACHE[key] = matcher
288
- return matcher
289
-
290
-
291
- def _get_lsp_inspector(root: Path) -> LspInspector:
292
- key = str(root.resolve())
293
- inspector = _LSP_INSPECTOR_CACHE.get(key)
294
- if inspector is None:
295
- inspector = LspInspector(root)
296
- _LSP_INSPECTOR_CACHE[key] = inspector
297
- return inspector
298
-
299
-
300
- def _get_graph_adapter(root: Path) -> CodeReviewGraphAdapter:
301
- key = str(root.resolve())
302
- adapter = _GRAPH_ADAPTER_CACHE.get(key)
303
- if adapter is None:
304
- adapter = CodeReviewGraphAdapter(root)
305
- _GRAPH_ADAPTER_CACHE[key] = adapter
306
- return adapter
307
-
308
-
309
- def _load_router(root: Path):
310
- """Build a ModelRouter from project config, or return None when no provider key
311
- is configured. When present, the verifier runs DevCouncil's strong compiled
312
- per-criterion acceptance checks; when None, it falls back to coarse mode (which
313
- the verify response now reports explicitly so the agent is never misled).
314
-
315
- Cached per resolved project root: a verify_task storm would otherwise rebuild the
316
- config+provider+router on every call. The cache is invalidated when config.yaml's
317
- stat signature changes, so a long-running server picks up a rewritten config (new
318
- provider key / model) instead of serving a stale router. Use _reset_caches() to clear
319
- in tests."""
320
- key = str(root.resolve())
321
- try:
322
- cfg_stat = (root / ".devcouncil" / "config.yaml").stat()
323
- signature: object = (cfg_stat.st_mtime_ns, cfg_stat.st_size, cfg_stat.st_ino)
324
- except OSError:
325
- signature = None
326
- cached = _ROUTER_CACHE.get(key)
327
- if cached is not None and cached[0] == signature:
328
- return cached[1]
329
- router = _build_router(root)
330
- _ROUTER_CACHE[key] = (signature, router)
331
- return router
332
-
333
-
334
- def _build_router(root: Path):
335
- try:
336
- from devcouncil.app.config import load_config, get_api_key
337
- from devcouncil.llm.provider import create_provider, validate_model_provider
338
- from devcouncil.llm.router import ModelRouter
339
-
340
- config = load_config(root)
341
- validate_model_provider(config.models.provider)
342
- api_key = get_api_key(config.models.provider, root)
343
- provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
344
- role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
345
- return ModelRouter(provider, role_config, project_root=root)
346
- except Exception:
347
- return None
348
-
151
+ @app.get_prompt()
152
+ async def get_prompt(name: str, arguments: dict | None):
153
+ return prompt_handlers.get_prompt(name, arguments, _project_root())
349
154
 
350
- def _error_text(message: str, *, code: str = "error", **details: object) -> list[TextContent]:
351
- return _json_text({"ok": False, "error": message, "code": code, **details})
352
155
 
156
+ @app.call_tool()
157
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
158
+ arguments = _normalize_arguments(arguments)
159
+ root = _project_root()
160
+ log_step(f"mcp/{name}: invoked", project_root=root)
161
+ logger.info("MCP call_tool: %s args=%s", name, sorted(arguments) if isinstance(arguments, dict) else arguments)
162
+ db = get_db(root)
163
+ if name in _DB_REQUIRED_TOOLS and not db:
164
+ logger.warning("MCP tool %s rejected: project not initialized at %s", name, root)
165
+ return _error_text("DevCouncil not initialized in this directory.", code="not_initialized")
353
166
 
354
- def _normalize_arguments(arguments: object) -> dict:
355
- return arguments if isinstance(arguments, dict) else {}
167
+ registered = await codeintel_handlers.dispatch(name, root, arguments)
168
+ if registered is not None:
169
+ return registered
170
+ registered = await debug_handlers.dispatch(name, root, arguments)
171
+ if registered is not None:
172
+ return registered
356
173
 
174
+ if name == "devcouncil_integration_status":
175
+ return _json_text(integration_status_summary(root))
357
176
 
358
- def _int_argument(arguments: dict, name: str, default: int, *, minimum: int, maximum: int) -> int:
359
- value = arguments.get(name, default)
360
- if not isinstance(value, int) or isinstance(value, bool):
361
- value = default
362
- return max(minimum, min(value, maximum))
177
+ if name == "devcouncil_status":
178
+ assert db is not None
179
+ return await status_handlers.handle_status(root, db, arguments)
363
180
 
181
+ if name == "devcouncil_report":
182
+ assert db is not None
183
+ return await status_handlers.handle_report(root, db, arguments)
364
184
 
365
- def _optional_string_argument(arguments: dict, name: str) -> str | None:
366
- value = arguments.get(name)
367
- if value is None:
368
- return None
369
- return value if isinstance(value, str) else ""
185
+ if name == "devcouncil_live_review":
186
+ return await live_handlers.handle_live_review(root, arguments)
370
187
 
188
+ if name == "devcouncil_live_cards":
189
+ return await live_handlers.handle_live_cards(root, arguments)
371
190
 
372
- def _optional_string_list_argument(arguments: dict, name: str) -> tuple[list[str], list[TextContent] | None]:
373
- value = arguments.get(name)
374
- if value is None:
375
- return [], None
376
- if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
377
- return [], _error_text(f"{name} must be a string array", code="invalid_arguments", argument=name)
378
- return value, None
191
+ if name == "devcouncil_live_repair_prompt":
192
+ return await live_handlers.handle_live_repair_prompt(root, arguments)
379
193
 
194
+ if name == "devcouncil_live_repair_all":
195
+ return await live_handlers.handle_live_repair_all(root, arguments)
380
196
 
381
- def _required_string_argument(arguments: dict, name: str) -> tuple[str | None, list[TextContent] | None]:
382
- value = arguments.get(name)
383
- if value is None or value == "":
384
- return None, _error_text(f"Missing {name}", code="missing_argument", argument=name)
385
- if not isinstance(value, str):
386
- return None, _error_text(f"{name} must be a string", code="invalid_arguments", argument=name)
387
- return value, None
197
+ if name == "devcouncil_get_task":
198
+ assert db is not None
199
+ return await task_handlers.handle_get_task(root, db, arguments)
388
200
 
201
+ if name == "devcouncil_get_gaps":
202
+ assert db is not None
203
+ return await status_handlers.handle_get_gaps(root, db, arguments)
389
204
 
390
- def _run_cli_command(args: list[str], root: Path) -> dict[str, object]:
391
- command = [sys.executable, "-m", "devcouncil", *args, "--project-root", str(root)]
392
- try:
393
- result = subprocess.run(
394
- command,
395
- cwd=root,
396
- capture_output=True,
397
- text=True,
398
- encoding="utf-8",
399
- errors="replace",
400
- timeout=_CLI_TIMEOUT_SECONDS,
401
- )
402
- stdout, stdout_truncated = _truncate_text(result.stdout)
403
- stderr, stderr_truncated = _truncate_text(result.stderr)
404
- return {
405
- "ok": result.returncode == 0,
406
- "returncode": result.returncode,
407
- "stdout": stdout,
408
- "stderr": stderr,
409
- "stdout_truncated": stdout_truncated,
410
- "stderr_truncated": stderr_truncated,
411
- "timed_out": False,
412
- }
413
- except subprocess.TimeoutExpired as exc:
414
- stdout, stdout_truncated = _truncate_text(exc.output)
415
- stderr, stderr_truncated = _truncate_text(exc.stderr)
416
- return {
417
- "ok": False,
418
- "returncode": None,
419
- "stdout": stdout,
420
- "stderr": stderr,
421
- "stdout_truncated": stdout_truncated,
422
- "stderr_truncated": stderr_truncated,
423
- "timed_out": True,
424
- "timeout_seconds": _CLI_TIMEOUT_SECONDS,
425
- }
205
+ if name == "devcouncil_get_next_actions":
206
+ assert db is not None
207
+ return await status_handlers.handle_get_next_actions(root, db, arguments)
426
208
 
209
+ if name == "devcouncil_get_task_provenance":
210
+ assert db is not None
211
+ return await provenance_handlers.handle_get_task_provenance(root, db, arguments)
427
212
 
428
- def _project_root() -> Path:
429
- configured = os.environ.get("DEVCOUNCIL_PROJECT_ROOT")
430
- return Path(configured).expanduser().resolve() if configured else Path(".")
213
+ if name == "devcouncil_list_tasks":
214
+ assert db is not None
215
+ return await status_handlers.handle_list_tasks(root, db, arguments)
431
216
 
217
+ if name == "devcouncil_get_prompt":
218
+ assert db is not None
219
+ return await task_handlers.handle_get_prompt(root, db, arguments)
432
220
 
433
- def _knowledge_source_uri(kind: str, name: str) -> str:
434
- """Stable, parseable resource URI for one ingested knowledge source.
221
+ if name == "devcouncil_tail_trace":
222
+ return await trace_handlers.handle_tail_trace(root, arguments)
435
223
 
436
- The name is percent-encoded so OKF/design source names with spaces or slashes
437
- still yield a valid AnyUrl and round-trip cleanly through read_resource."""
438
- from urllib.parse import quote
224
+ if name == "devcouncil_policy_check_write":
225
+ assert db is not None
226
+ return await policy_handlers.handle_policy_check_write(root, db, arguments)
439
227
 
440
- return f"devcouncil://knowledge/{kind}/{quote(name, safe='')}"
228
+ if name == "devcouncil_graph_context":
229
+ return await graph_handlers.handle_graph_context(root, arguments)
441
230
 
231
+ if name == "devcouncil_repo_map":
232
+ return await map_handlers.handle_repo_map(root, arguments)
442
233
 
443
- def _discover_knowledge_sources(root: Path) -> list:
444
- """Best-effort enumeration of ingested OKF/design knowledge for the project.
234
+ if name == "devcouncil_impact":
235
+ return await map_handlers.handle_impact(root, arguments)
445
236
 
446
- A broken or absent knowledge layer must never break resource listing, so any
447
- failure degrades to an empty list (mirrors the other optional handlers here).
237
+ if name == "devcouncil_liveness":
238
+ return await map_handlers.handle_liveness(root, arguments)
448
239
 
449
- Honors the project's ``knowledge`` config (enabled / directory / design_always) so MCP
450
- exposes exactly what the planning and task prompts do — otherwise a project that
451
- disabled or relocated its knowledge would still leak it through MCP resources."""
452
- try:
453
- from devcouncil.knowledge.sources import discover_knowledge_sources
240
+ if name == "devcouncil_graph_query":
241
+ return await map_handlers.handle_graph_query(root, arguments)
454
242
 
455
- directory, design_always = _knowledge_settings(root)
456
- if directory is None: # explicitly disabled in config
457
- return []
458
- return discover_knowledge_sources(root, directory=directory, design_always=design_always)
459
- except Exception:
460
- return []
243
+ if name == "devcouncil_graph_trace":
244
+ return await map_handlers.handle_graph_trace(root, arguments)
461
245
 
246
+ if name == "devcouncil_graph_impact":
247
+ return await map_handlers.handle_graph_impact(root, arguments)
462
248
 
463
- def _knowledge_settings(root: Path) -> tuple[str | None, bool]:
464
- """Resolve (directory, design_always) for knowledge exposure from project config.
249
+ if name == "devcouncil_graph_ingest":
250
+ return await map_handlers.handle_graph_ingest(root, arguments)
465
251
 
466
- Returns ``(None, _)`` when the project explicitly disables knowledge so callers can
467
- suppress it. Falls back to defaults when no/invalid config is present (the MCP server
468
- must keep working for projects without a full ``.devcouncil/config.yaml``)."""
469
- try:
470
- from devcouncil.app.config import load_config
252
+ if name == "devcouncil_graph_cypher":
253
+ return await map_handlers.handle_graph_cypher(root, arguments)
471
254
 
472
- cfg = load_config(root).knowledge
473
- return (None if not cfg.enabled else cfg.directory), cfg.design_always
474
- except Exception:
475
- return ".devcouncil/knowledge", True
255
+ if name == "devcouncil_pdg_query":
256
+ return await map_handlers.handle_pdg_query(root, arguments)
476
257
 
258
+ if name == "devcouncil_explain":
259
+ return await map_handlers.handle_explain(root, arguments)
477
260
 
478
- def _is_secret_path(root: Path, rel_or_abs: str) -> bool:
479
- """True when a path matches a protected secret/credential glob.
261
+ if name == "devcouncil_route_map":
262
+ return await map_handlers.handle_route_map(root, arguments)
480
263
 
481
- Reuses the shared SECRET_PATH_PATTERNS (the single source of truth in the policy
482
- engine) so read tools refuse exactly the same files the write gate refuses — an
483
- MCP agent must never be able to exfiltrate a credential through a read tool."""
484
- from devcouncil.execution.policy_engine import SECRET_PATH_PATTERNS
264
+ if name == "devcouncil_shape_check":
265
+ return await map_handlers.handle_shape_check(root, arguments)
485
266
 
486
- normalized = rel_or_abs.strip().strip('"').replace("\\", "/")
487
- try:
488
- candidate = Path(normalized)
489
- if candidate.is_absolute():
490
- resolved = candidate.resolve()
491
- try:
492
- normalized = resolved.relative_to(root.resolve()).as_posix()
493
- except ValueError:
494
- normalized = resolved.as_posix()
495
- except OSError:
496
- pass
497
- import fnmatch as _fnmatch
498
-
499
- return any(_fnmatch.fnmatch(normalized, pattern) for pattern in SECRET_PATH_PATTERNS)
500
-
501
-
502
- def _allowed_next_tools(status: str, has_blocking_gaps: bool) -> list[str]:
503
- """Compute the self-describing next-tool contract from task state.
504
-
505
- Replaces a hardcoded list so an agent is steered by what the task actually
506
- needs: a verified task only needs releasing; a blocked/running task gets the
507
- full read->edit->test loop; a planned task should check out first."""
508
- if status == "verified":
509
- return ["devcouncil_release_task"]
510
- if status == "done":
511
- return []
512
- if status in {"running", "blocked"} or has_blocking_gaps:
513
- return [
514
- "devcouncil_read_file",
515
- "devcouncil_get_evidence",
516
- "devcouncil_get_diff",
517
- "devcouncil_run_command",
518
- "devcouncil_apply_patch",
519
- "devcouncil_write_file",
520
- "devcouncil_verify_task",
521
- ]
522
- # planned / ready and not yet leased: bootstrap by checking out.
523
- return [
524
- "devcouncil_checkout_task",
525
- "devcouncil_read_file",
526
- "devcouncil_get_diff",
527
- ]
267
+ if name == "devcouncil_api_impact":
268
+ return await map_handlers.handle_api_impact(root, arguments)
528
269
 
270
+ if name == "devcouncil_lsp_status":
271
+ return await ast_lsp_handlers.handle_lsp_status(root, arguments)
529
272
 
530
- @app.list_tools()
531
- async def list_tools() -> list[Tool]:
532
- return [
533
- Tool(
534
- name="devcouncil_status",
535
- description="Get the current status of the DevCouncil project, including phase, tasks, and gaps.",
536
- inputSchema={
537
- "type": "object",
538
- "properties": {}
539
- }
540
- ),
541
- Tool(
542
- name="devcouncil_integration_status",
543
- description="Get read-only coding CLI integration status, capability rows, detected clients, and recommended executor.",
544
- inputSchema={"type": "object", "properties": {}},
545
- ),
546
- Tool(
547
- name="devcouncil_report",
548
- description="Get the full coverage report and a list of all requirements and blocking gaps.",
549
- inputSchema={
550
- "type": "object",
551
- "properties": {}
552
- }
553
- ),
554
- Tool(
555
- name="devcouncil_get_task",
556
- description="Get details, constraints, and requirements for a specific implementation task.",
557
- inputSchema={
558
- "type": "object",
559
- "properties": {
560
- "task_id": {
561
- "type": "string",
562
- "description": "The ID of the task, e.g. TASK-001"
563
- }
564
- },
565
- "required": ["task_id"]
566
- }
567
- ),
568
- Tool(
569
- name="devcouncil_get_gaps",
570
- description=(
571
- "Read the persisted verification gaps for a task WITHOUT re-running "
572
- "verification. Cheap and idempotent — use it to resume after a "
573
- "reconnect or to inspect outstanding work before deciding to repair."
574
- ),
575
- inputSchema={
576
- "type": "object",
577
- "properties": {
578
- "task_id": {"type": "string"},
579
- "blocking_only": {"type": "boolean", "default": False},
580
- },
581
- "required": ["task_id"],
582
- },
583
- ),
584
- Tool(
585
- name="devcouncil_get_next_actions",
586
- description=(
587
- "Get the typed, machine-routable next-actions contract for a task from "
588
- "its persisted gaps, WITHOUT re-verifying. Returns blocking next_actions "
589
- "plus advisory_actions and the tools allowed next."
590
- ),
591
- inputSchema={
592
- "type": "object",
593
- "properties": {"task_id": {"type": "string"}},
594
- "required": ["task_id"],
595
- },
596
- ),
597
- Tool(
598
- name="devcouncil_get_task_provenance",
599
- description=(
600
- "Inspect the recorded audit trail for a task: gated file changes "
601
- "(write_file/apply_patch and hook events), verification runs, diff-coverage "
602
- "evidence (was the changed code actually exercised), and the latest "
603
- "correction manifest. Read-only — lets a developer or agent trust what "
604
- "actually happened on disk."
605
- ),
606
- inputSchema={
607
- "type": "object",
608
- "properties": {"task_id": {"type": "string"}},
609
- "required": ["task_id"],
610
- },
611
- ),
612
- Tool(
613
- name="devcouncil_live_review",
614
- description="Get live coding-agent review status, pending signals, critique-card counts, and blockers.",
615
- inputSchema={
616
- "type": "object",
617
- "properties": {
618
- "task_id": {
619
- "type": "string",
620
- "description": "Optional task scope for live-review blocker calculation.",
621
- }
622
- },
623
- },
624
- ),
625
- Tool(
626
- name="devcouncil_live_cards",
627
- description="List live-review critique cards with optional task, status, verdict, and client filters.",
628
- inputSchema={
629
- "type": "object",
630
- "properties": {
631
- "task_id": {
632
- "type": "string",
633
- "description": "Optional task scope for critique cards.",
634
- },
635
- "status": {
636
- "type": "string",
637
- "enum": ["open", "resolved", "ignored"],
638
- "description": "Optional card status filter.",
639
- },
640
- "verdict": {
641
- "type": "string",
642
- "enum": ["approved", "concerns", "critical"],
643
- "description": "Optional card verdict filter.",
644
- },
645
- "client": {
646
- "type": "string",
647
- "description": "Optional coding-agent client filter.",
648
- },
649
- "limit": {
650
- "type": "integer",
651
- "minimum": 1,
652
- "maximum": 200,
653
- "default": 20,
654
- },
655
- },
656
- },
657
- ),
658
- Tool(
659
- name="devcouncil_live_repair_prompt",
660
- description="Generate a ready-to-paste repair prompt for a live-review critique card.",
661
- inputSchema={
662
- "type": "object",
663
- "properties": {
664
- "card_id": {
665
- "type": "string",
666
- "description": "The critique card ID, e.g. CARD-abc123.",
667
- }
668
- },
669
- "required": ["card_id"],
670
- },
671
- ),
672
- Tool(
673
- name="devcouncil_live_repair_all",
674
- description="Generate one repair prompt for all blocking live-review critique cards in scope.",
675
- inputSchema={
676
- "type": "object",
677
- "properties": {
678
- "task_id": {
679
- "type": "string",
680
- "description": "Optional task scope for blocking live-review cards.",
681
- }
682
- },
683
- },
684
- ),
685
- Tool(
686
- name="devcouncil_list_tasks",
687
- description="List DevCouncil tasks with status and requirement mappings. Supports a status filter and limit/offset paging so large projects don't blow the agent's context.",
688
- inputSchema={
689
- "type": "object",
690
- "properties": {
691
- "status": {"type": "string", "description": "Optional status filter (e.g. planned, running, blocked, verified, done)."},
692
- "limit": {"type": "integer", "description": "Max tasks to return (default 100, max 500)."},
693
- "offset": {"type": "integer", "description": "Number of tasks to skip (default 0)."},
694
- },
695
- },
696
- ),
697
- Tool(
698
- name="devcouncil_get_prompt",
699
- description="Get the raw implementation prompt for a DevCouncil task.",
700
- inputSchema={
701
- "type": "object",
702
- "properties": {
703
- "task_id": {"type": "string", "description": "The ID of the task, e.g. TASK-001"},
704
- },
705
- "required": ["task_id"],
706
- },
707
- ),
708
- Tool(
709
- name="devcouncil_tail_trace",
710
- description="Return recent DevCouncil trace events as JSON.",
711
- inputSchema={
712
- "type": "object",
713
- "properties": {
714
- "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20},
715
- },
716
- },
717
- ),
718
- Tool(
719
- name="devcouncil_policy_check_write",
720
- description="Check whether a file write is allowed for a task or the active running task.",
721
- inputSchema={
722
- "type": "object",
723
- "properties": {
724
- "path": {"type": "string", "description": "Repository-relative or absolute path to check."},
725
- "task_id": {"type": "string", "description": "Optional task ID. Defaults to the running task."},
726
- },
727
- "required": ["path"],
728
- },
729
- ),
730
- Tool(
731
- name="devcouncil_graph_context",
732
- description="Get optional code-review-graph structural context for changed or planned files.",
733
- inputSchema={
734
- "type": "object",
735
- "properties": {
736
- "files": {
737
- "type": "array",
738
- "items": {"type": "string"},
739
- "description": "Repository-relative files to contextualize.",
740
- }
741
- },
742
- },
743
- ),
744
- Tool(
745
- name="devcouncil_lsp_status",
746
- description="Return detected language servers and starter LSP initialize payloads.",
747
- inputSchema={"type": "object", "properties": {}},
748
- ),
749
- Tool(
750
- name="devcouncil_ast_match",
751
- description="Search code symbols structurally using optional tree-sitter support and deterministic fallbacks.",
752
- inputSchema={
753
- "type": "object",
754
- "properties": {
755
- "query": {"type": "string"},
756
- "language": {"type": "string"},
757
- "kind": {"type": "string"},
758
- "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
759
- },
760
- },
761
- ),
762
- Tool(
763
- name="devcouncil_cli",
764
- description="Run a safe DevCouncil CLI command for status, tasks, report, map, prompt, show, trace, lsp, or ast.",
765
- inputSchema={
766
- "type": "object",
767
- "properties": {
768
- "args": {
769
- "type": "array",
770
- "items": {"type": "string"},
771
- "description": "Arguments after the dev command, for example ['status','--json'].",
772
- }
773
- },
774
- "required": ["args"],
775
- },
776
- ),
777
- Tool(
778
- name="devcouncil_prepare_execution",
779
- description="Return a task prompt plus planned files and allowed commands for external execution tooling.",
780
- inputSchema={
781
- "type": "object",
782
- "properties": {
783
- "task_id": {"type": "string", "description": "The ID of the task, e.g. TASK-001"},
784
- },
785
- "required": ["task_id"],
786
- },
787
- ),
788
- Tool(
789
- name="devcouncil_checkout_task",
790
- description="Acquire a task lease and return scope for MCP write tools.",
791
- inputSchema={
792
- "type": "object",
793
- "properties": {
794
- "task_id": {"type": "string"},
795
- "client_id": {"type": "string"},
796
- "agent": {"type": "string"},
797
- "force": {"type": "boolean", "default": False},
798
- },
799
- "required": ["task_id", "client_id"],
800
- },
801
- ),
802
- Tool(
803
- name="devcouncil_release_task",
804
- description="Release a task lease using its token.",
805
- inputSchema={
806
- "type": "object",
807
- "properties": {
808
- "task_id": {"type": "string"},
809
- "lease_token": {"type": "string"},
810
- },
811
- "required": ["task_id", "lease_token"],
812
- },
813
- ),
814
- Tool(
815
- name="devcouncil_renew_lease",
816
- description=(
817
- "Extend a held task lease's TTL so a long-running agent does not lose it "
818
- "to expiry. Returns the new expires_at."
819
- ),
820
- inputSchema={
821
- "type": "object",
822
- "properties": {
823
- "task_id": {"type": "string"},
824
- "lease_token": {"type": "string"},
825
- "ttl_seconds": {"type": "integer"},
826
- },
827
- "required": ["task_id", "lease_token"],
828
- },
829
- ),
830
- Tool(
831
- name="devcouncil_list_leases",
832
- description=(
833
- "List task leases for fleet supervision — task_id, owner, agent, "
834
- "expires_at, and whether each is expired. Defaults to active leases."
835
- ),
836
- inputSchema={
837
- "type": "object",
838
- "properties": {"active_only": {"type": "boolean", "default": True}},
839
- },
840
- ),
841
- Tool(
842
- name="devcouncil_update_task_scope",
843
- description="Append unique expected tests or allowed commands for a leased task.",
844
- inputSchema={
845
- "type": "object",
846
- "properties": {
847
- "task_id": {"type": "string"},
848
- "lease_token": {"type": "string"},
849
- "expected_tests": {"type": "array", "items": {"type": "string"}},
850
- "allowed_commands": {"type": "array", "items": {"type": "string"}},
851
- },
852
- "required": ["task_id", "lease_token"],
853
- },
854
- ),
855
- Tool(
856
- name="devcouncil_append_evidence",
857
- description="Append command evidence for a leased task.",
858
- inputSchema={
859
- "type": "object",
860
- "properties": {
861
- "task_id": {"type": "string"},
862
- "lease_token": {"type": "string"},
863
- "command": {"type": "string"},
864
- "exit_code": {"type": "integer"},
865
- "summary": {"type": "string"},
866
- },
867
- "required": ["task_id", "lease_token", "command", "exit_code", "summary"],
868
- },
869
- ),
870
- Tool(
871
- name="devcouncil_record_command",
872
- description="Record a shell command event for a leased task.",
873
- inputSchema={
874
- "type": "object",
875
- "properties": {
876
- "task_id": {"type": "string"},
877
- "lease_token": {"type": "string"},
878
- "command": {"type": "string"},
879
- "status": {"type": "string", "enum": ["started", "finished", "failed", "blocked"]},
880
- "exit_code": {"type": "integer"},
881
- "reason": {"type": "string"},
882
- },
883
- "required": ["task_id", "lease_token", "command", "status"],
884
- },
885
- ),
886
- Tool(
887
- name="devcouncil_write_file",
888
- description=(
889
- "Write a file for a leased task through DevCouncil's policy gate. The write "
890
- "is checked against the task's scope BEFORE it lands (out-of-scope or "
891
- "protected paths are rejected), applied atomically, and recorded as a "
892
- "FileChangeEvent. Returns applied_files and rejected_files."
893
- ),
894
- inputSchema={
895
- "type": "object",
896
- "properties": {
897
- "task_id": {"type": "string"},
898
- "lease_token": {"type": "string"},
899
- "path": {"type": "string"},
900
- "content": {"type": "string"},
901
- },
902
- "required": ["task_id", "lease_token", "path", "content"],
903
- },
904
- ),
905
- Tool(
906
- name="devcouncil_apply_patch",
907
- description=(
908
- "Apply a unified diff for a leased task through DevCouncil's policy gate. "
909
- "EVERY target file is policy-checked first; if any is out of scope the whole "
910
- "patch is rejected (never partially applied). Applied atomically via git and "
911
- "each file recorded as a FileChangeEvent. Returns applied_files/rejected_files."
912
- ),
913
- inputSchema={
914
- "type": "object",
915
- "properties": {
916
- "task_id": {"type": "string"},
917
- "lease_token": {"type": "string"},
918
- "unified_diff": {"type": "string"},
919
- },
920
- "required": ["task_id", "lease_token", "unified_diff"],
921
- },
922
- ),
923
- Tool(
924
- name="devcouncil_verify_task",
925
- description="Run verification for a leased task (local sandbox).",
926
- inputSchema={
927
- "type": "object",
928
- "properties": {
929
- "task_id": {"type": "string"},
930
- "lease_token": {"type": "string"},
931
- "sandbox": {"type": "string", "enum": ["local"], "default": "local", "description": "Only 'local' is supported in this build."},
932
- },
933
- "required": ["task_id", "lease_token"],
934
- },
935
- ),
936
- Tool(
937
- name="devcouncil_handoff_agent",
938
- description="Hand off a task between coding CLI agents.",
939
- inputSchema={
940
- "type": "object",
941
- "properties": {
942
- "task_id": {"type": "string"},
943
- "lease_token": {"type": "string"},
944
- "from_agent": {"type": "string"},
945
- "to_agent": {"type": "string"},
946
- "instruction": {"type": "string"},
947
- },
948
- "required": ["task_id", "lease_token", "from_agent", "to_agent"],
949
- },
950
- ),
951
- Tool(
952
- name="devcouncil_read_file",
953
- description=(
954
- "Read a repository file (read-only, no lease required) so an MCP-only "
955
- "agent can inspect content before constructing a diff or overwriting it. "
956
- "Containment-checked against the project root and refuses secret/credential "
957
- "paths. Supports offset/limit or line_range windowing. Returns content "
958
- "(truncated), sha256, and line_count."
959
- ),
960
- inputSchema={
961
- "type": "object",
962
- "properties": {
963
- "path": {"type": "string", "description": "Repository-relative or absolute path inside the project."},
964
- "offset": {"type": "integer", "minimum": 0, "description": "0-based line offset to start from."},
965
- "limit": {"type": "integer", "minimum": 1, "description": "Max number of lines to return."},
966
- "line_range": {
967
- "type": "string",
968
- "description": "Inclusive 1-based line range like '10-40' (overrides offset/limit).",
969
- },
970
- },
971
- "required": ["path"],
972
- },
973
- ),
974
- Tool(
975
- name="devcouncil_get_diff",
976
- description=(
977
- "Return the working-tree diff for the project (requires a git repo). When "
978
- "task_id is given the diff is scoped to that task's planned/changed files. "
979
- "Set staged=true to include the staged (git diff --cached) changes. Returns "
980
- "per-file status with additions/deletions and the truncated unified diff."
981
- ),
982
- inputSchema={
983
- "type": "object",
984
- "properties": {
985
- "task_id": {"type": "string", "description": "Optional task to scope the diff to its files."},
986
- "paths": {
987
- "type": "array",
988
- "items": {"type": "string"},
989
- "description": "Optional explicit repo-relative paths to scope the diff to.",
990
- },
991
- "staged": {"type": "boolean", "default": False, "description": "Include staged changes."},
992
- },
993
- },
994
- ),
995
- Tool(
996
- name="devcouncil_get_evidence",
997
- description=(
998
- "Read persisted CommandResult evidence for a task and inline the truncated "
999
- "stdout/stderr from the stored log files (best-effort; tolerates missing "
1000
- "files). Pairs with verification to close the diagnose leg of the loop."
1001
- ),
1002
- inputSchema={
1003
- "type": "object",
1004
- "properties": {
1005
- "task_id": {"type": "string"},
1006
- "command": {"type": "string", "description": "Optional substring filter on the recorded command."},
1007
- "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
1008
- },
1009
- "required": ["task_id"],
1010
- },
1011
- ),
1012
- Tool(
1013
- name="devcouncil_run_command",
1014
- description=(
1015
- "Run a command for a leased task through DevCouncil's allowlist gate. The "
1016
- "command must pass the task's allowed_commands policy (same gate as the "
1017
- "hooks); otherwise it is refused and nothing runs. Executed with a clean "
1018
- "subprocess env and a timeout, recorded as a ShellCommandEvent. Returns "
1019
- "exit_code and truncated stdout/stderr."
1020
- ),
1021
- inputSchema={
1022
- "type": "object",
1023
- "properties": {
1024
- "task_id": {"type": "string"},
1025
- "lease_token": {"type": "string"},
1026
- "command": {"type": "string"},
1027
- },
1028
- "required": ["task_id", "lease_token", "command"],
1029
- },
1030
- ),
1031
- Tool(
1032
- name="devcouncil_list_agent_runs",
1033
- description=(
1034
- "List recorded coding-agent runs (from .devcouncil/runs/*/agent-run.json), "
1035
- "newest first. Each entry includes run_id, task, agent, profile, status, "
1036
- "started time, and an orphaned flag for runs still marked running whose "
1037
- "manifest has gone stale (executor likely crashed). Read-only."
1038
- ),
1039
- inputSchema={
1040
- "type": "object",
1041
- "properties": {
1042
- "status": {"type": "string", "description": "Optional status filter (e.g. running, finished, failed, timeout)."},
1043
- "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 20},
1044
- },
1045
- },
1046
- ),
1047
- Tool(
1048
- name="devcouncil_get_run",
1049
- description=(
1050
- "Get the full manifest for a single coding-agent run plus a redacted "
1051
- "transcript tail when a transcript/log file exists in the run directory. "
1052
- "Includes the resolved CLI invocation and an orphaned flag. Read-only."
1053
- ),
1054
- inputSchema={
1055
- "type": "object",
1056
- "properties": {
1057
- "run_id": {"type": "string", "description": "The run id to inspect."},
1058
- },
1059
- "required": ["run_id"],
1060
- },
1061
- ),
1062
- Tool(
1063
- name="devcouncil_next_task",
1064
- description=(
1065
- "Return the highest-priority task that is unblocked (its depends_on are "
1066
- "satisfied) and has no active lease, so an autonomous agent can bootstrap "
1067
- "deterministically instead of racing list_tasks. Includes a blocking-gap "
1068
- "summary and a ready_to_checkout flag."
1069
- ),
1070
- inputSchema={
1071
- "type": "object",
1072
- "properties": {
1073
- "client_id": {"type": "string", "description": "Optional client id (informational)."},
1074
- "status": {"type": "string", "description": "Optional status filter (default planned/ready)."},
1075
- },
1076
- },
1077
- ),
1078
- Tool(
1079
- name="devcouncil_select_knowledge",
1080
- description=(
1081
- "Select the ingested project knowledge (OKF documents and the design "
1082
- "system) that applies to a goal and return it as a ready-to-inject "
1083
- "markdown preamble, so a coding agent can ask 'what project knowledge "
1084
- "applies to <goal>?'. Always-on design knowledge is included; OKF "
1085
- "documents are matched on goal keywords. Returns the matched sources "
1086
- "and the rendered preamble."
1087
- ),
1088
- inputSchema={
1089
- "type": "object",
1090
- "properties": {
1091
- "goal": {"type": "string", "description": "The task or goal to find applicable knowledge for."},
1092
- },
1093
- "required": ["goal"],
1094
- },
1095
- ),
1096
- ]
273
+ if name == "devcouncil_ast_match":
274
+ return await ast_lsp_handlers.handle_ast_match(root, arguments)
1097
275
 
1098
- @app.list_resources()
1099
- async def list_resources() -> list[Resource]:
1100
- """Expose the DevCouncil corpus as browsable/subscribable MCP resources, so a host
1101
- can read the report, task graph, gaps, and live-review state without a tool call."""
1102
- root = _project_root()
1103
- resources: list[Resource] = [
1104
- Resource(uri=AnyUrl("devcouncil://report"), name="DevCouncil report",
1105
- description="Coverage report, requirement/task mapping, and blocking gaps.",
1106
- mimeType="text/markdown"),
1107
- Resource(uri=AnyUrl("devcouncil://tasks"), name="Tasks",
1108
- description="All planned tasks with scope and status.", mimeType="application/json"),
1109
- Resource(uri=AnyUrl("devcouncil://gaps"), name="Gaps",
1110
- description="All open verification gaps.", mimeType="application/json"),
1111
- Resource(uri=AnyUrl("devcouncil://cards"), name="Live review",
1112
- description="Live-review summary: cards, signals, and blockers.",
1113
- mimeType="application/json"),
1114
- ]
1115
- db = get_db(root)
1116
- if db:
1117
- with db.get_session() as session:
1118
- for task in TaskRepository(session).get_all():
1119
- resources.append(Resource(
1120
- uri=AnyUrl(f"devcouncil://task/{task.id}"),
1121
- name=f"Task {task.id}: {task.title}",
1122
- description=f"Scope, status, and gaps for {task.id}.",
1123
- mimeType="application/json",
1124
- ))
1125
- # Project knowledge (ingested OKF + design.md) — surfaced only when something has
1126
- # actually been ingested, so hosts without a knowledge layer see no empty entries.
1127
- knowledge_sources = _discover_knowledge_sources(root)
1128
- if knowledge_sources:
1129
- resources.append(Resource(
1130
- uri=AnyUrl("devcouncil://knowledge"),
1131
- name="Project knowledge",
1132
- description="Index of ingested OKF and design knowledge for this project.",
1133
- mimeType="text/markdown",
1134
- ))
1135
- for source in knowledge_sources:
1136
- resources.append(Resource(
1137
- uri=AnyUrl(_knowledge_source_uri(source.kind, source.name)),
1138
- name=f"Knowledge ({source.kind}): {source.description or source.name}",
1139
- description=source.description or source.name,
1140
- mimeType="text/markdown",
1141
- ))
1142
- return resources
276
+ if name == "devcouncil_cli":
277
+ return await cli_gate_handlers.handle_cli(root, arguments)
1143
278
 
279
+ if name == "devcouncil_prepare_execution":
280
+ assert db is not None
281
+ return await task_handlers.handle_prepare_execution(root, db, arguments)
1144
282
 
1145
- @app.read_resource()
1146
- async def read_resource(uri: AnyUrl) -> str:
1147
- root = _project_root()
1148
- db = get_db(root)
1149
- key = str(uri).rstrip("/")
1150
-
1151
- if key == "devcouncil://report":
1152
- if not db:
1153
- return "DevCouncil is not initialized in this directory."
1154
- with db.get_session() as session:
1155
- graph = ArtifactGraphRepository(session).load_graph()
1156
- return ReportBuilder.build_markdown(graph, live_review=live_review_summary(root))
1157
- if key == "devcouncil://tasks":
1158
- if not db:
1159
- return json.dumps({"tasks": []})
1160
- with db.get_session() as session:
1161
- tasks = [t.model_dump() for t in TaskRepository(session).get_all()]
1162
- return json.dumps({"tasks": tasks}, indent=2)
1163
- if key == "devcouncil://gaps":
1164
- if not db:
1165
- return json.dumps({"gaps": []})
1166
- with db.get_session() as session:
1167
- gaps = [g.model_dump() for g in GapRepository(session).get_all()]
1168
- return json.dumps({"gaps": gaps}, indent=2)
1169
- if key == "devcouncil://cards":
1170
- return json.dumps(live_review_summary(root), indent=2)
1171
- if key.startswith("devcouncil://task/"):
1172
- task_id = key.rsplit("/", 1)[-1]
1173
- if not db:
1174
- return json.dumps({"ok": False, "error": "not initialized"})
1175
- with db.get_session() as session:
1176
- task = TaskRepository(session).get_by_id(task_id)
1177
- if not task:
1178
- return json.dumps({"ok": False, "error": f"Task {task_id} not found."})
1179
- gaps = [g.model_dump() for g in GapRepository(session).get_for_task(task_id)]
1180
- return json.dumps({"task": task.model_dump(), "gaps": gaps}, indent=2)
1181
-
1182
- if key == "devcouncil://knowledge":
1183
- # Markdown index linking each ingested source to its per-source resource URI.
1184
- sources = _discover_knowledge_sources(root)
1185
- if not sources:
1186
- return "# Project knowledge\n\nNo OKF or design knowledge has been ingested for this project."
1187
- lines = ["# Project knowledge", "", "Ingested OKF and design knowledge for this project.", ""]
1188
- for kind in ("design", "okf"):
1189
- kind_sources = [s for s in sources if s.kind == kind]
1190
- if not kind_sources:
1191
- continue
1192
- lines.append(f"## {kind.upper() if kind == 'okf' else kind.capitalize()}")
1193
- lines.append("")
1194
- for source in kind_sources:
1195
- link = _knowledge_source_uri(source.kind, source.name)
1196
- desc = source.description or source.name
1197
- lines.append(f"- [{desc}]({link})")
1198
- lines.append("")
1199
- return "\n".join(lines).strip()
1200
- if key.startswith("devcouncil://knowledge/"):
1201
- # Match the requested URI back to a discovered source and render its markdown.
1202
- for source in _discover_knowledge_sources(root):
1203
- if _knowledge_source_uri(source.kind, source.name) == key:
1204
- return source.render() or source.body
1205
- return f"Knowledge source not found: {key}"
1206
-
1207
- raise ValueError(f"Unknown resource: {uri}")
1208
-
1209
-
1210
- # --- MCP prompts ---------------------------------------------------------------
1211
- # Exposed prompts surface in MCP hosts (Claude Code, Codex, ...) as slash commands
1212
- # (e.g. /mcp__devcouncil__implement_next_task). Each renders an actionable, DevCouncil-
1213
- # aware instruction block — injecting a live status snapshot when the project is
1214
- # initialized, and degrading to pure guidance when it is not. They steer a coding agent
1215
- # through the lease -> read -> edit -> verify -> release loop using the devcouncil_* tools.
1216
-
1217
- class _PromptSpec(NamedTuple):
1218
- name: str
1219
- description: str
1220
- arguments: list[PromptArgument]
1221
-
1222
-
1223
- _PROMPT_SPECS: list[_PromptSpec] = [
1224
- _PromptSpec(
1225
- name="devcouncil_implement_next_task",
1226
- description="Pick up the next unblocked DevCouncil task and implement it through the policy-gated MCP loop.",
1227
- arguments=[
1228
- PromptArgument(name="client_id", description="Optional stable client id used for the task lease.", required=False),
1229
- ],
1230
- ),
1231
- _PromptSpec(
1232
- name="devcouncil_repair_task",
1233
- description="Repair the blocking verification gaps for a task (defaults to the active running task).",
1234
- arguments=[
1235
- PromptArgument(name="task_id", description="Task id, e.g. TASK-001. Defaults to the active task.", required=False),
1236
- ],
1237
- ),
1238
- _PromptSpec(
1239
- name="devcouncil_verify_task",
1240
- description="Run DevCouncil verification for a task and report blocking gaps.",
1241
- arguments=[
1242
- PromptArgument(name="task_id", description="Task id, e.g. TASK-001. Defaults to the active task.", required=False),
1243
- ],
1244
- ),
1245
- _PromptSpec(
1246
- name="devcouncil_review_live",
1247
- description="Review pending live-review critique cards and resolve the blocking ones.",
1248
- arguments=[
1249
- PromptArgument(name="task_id", description="Optional task scope for the live-review cards.", required=False),
1250
- ],
1251
- ),
1252
- _PromptSpec(
1253
- name="devcouncil_project_status",
1254
- description="Summarize the current DevCouncil project phase, tasks, and blocking gaps.",
1255
- arguments=[],
1256
- ),
1257
- _PromptSpec(
1258
- name="devcouncil_apply_knowledge",
1259
- description="Select the ingested project knowledge (OKF + design) that applies to a goal and inject it.",
1260
- arguments=[
1261
- PromptArgument(name="goal", description="The task or goal to find applicable project knowledge for.", required=True),
1262
- ],
1263
- ),
1264
- ]
1265
-
1266
-
1267
- def _status_snapshot(root: Path) -> str:
1268
- """A short live status block for prompt bodies, or an init hint when uninitialized."""
1269
- db = get_db(root)
1270
- if not db:
1271
- return "DevCouncil is not initialized here yet — run `dev init` first."
1272
- try:
1273
- with db.get_session() as session:
1274
- graph = ArtifactGraphRepository(session).load_graph()
1275
- summary = graph.coverage_summary()
1276
- state = StateRepository(session).get_state()
1277
- phase = compute_phase(graph, state.current_phase if state else None)
1278
- return (
1279
- f"Phase: {phase} | "
1280
- f"tasks: {summary['total_tasks']} | "
1281
- f"gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking)"
1282
- )
1283
- except Exception:
1284
- return "DevCouncil status unavailable."
1285
-
1286
-
1287
- def _render_prompt_text(name: str, arguments: dict, root: Path) -> str:
1288
- snapshot = _status_snapshot(root)
1289
- if name == "devcouncil_implement_next_task":
1290
- client_id = arguments.get("client_id") or "claude-code"
1291
- return (
1292
- "You are implementing the next DevCouncil task under policy enforcement.\n\n"
1293
- f"Project status: {snapshot}\n\n"
1294
- "Do exactly this:\n"
1295
- "1. Call `devcouncil_next_task` to get the highest-priority unblocked task.\n"
1296
- f"2. Call `devcouncil_checkout_task` with that task_id and client_id='{client_id}' to acquire a lease.\n"
1297
- "3. Read the task scope with `devcouncil_get_task` and `devcouncil_get_prompt`; inspect files with `devcouncil_read_file` and `devcouncil_get_diff`.\n"
1298
- "4. Make changes ONLY through `devcouncil_write_file` / `devcouncil_apply_patch` (the policy gate rejects out-of-scope or protected paths) and run tests with `devcouncil_run_command`.\n"
1299
- "5. Call `devcouncil_verify_task`; if it reports blocking gaps, fix them and re-verify.\n"
1300
- "6. When verified, call `devcouncil_release_task` with the lease token.\n\n"
1301
- "Never edit files outside the task scope. If a write is rejected, call `devcouncil_update_task_scope` only when the change is legitimately in-scope."
1302
- )
1303
- if name == "devcouncil_repair_task":
1304
- task_id = arguments.get("task_id") or "(the active task)"
1305
- return (
1306
- f"Repair the blocking verification gaps for {task_id}.\n\n"
1307
- f"Project status: {snapshot}\n\n"
1308
- "1. Call `devcouncil_get_gaps` (blocking_only=true) and `devcouncil_get_next_actions` for the task.\n"
1309
- "2. Inspect the relevant files and evidence with `devcouncil_read_file` and `devcouncil_get_evidence`.\n"
1310
- "3. Apply minimal fixes via `devcouncil_apply_patch` / `devcouncil_write_file`.\n"
1311
- "4. Re-run `devcouncil_verify_task` until no blocking gaps remain, then `devcouncil_release_task`."
1312
- )
1313
- if name == "devcouncil_verify_task":
1314
- task_id = arguments.get("task_id") or "(the active task)"
1315
- return (
1316
- f"Run DevCouncil verification for {task_id} and report the result.\n\n"
1317
- f"Project status: {snapshot}\n\n"
1318
- "Call `devcouncil_verify_task` (you must hold the task lease via `devcouncil_checkout_task`). "
1319
- "Summarize the blocking gaps and proposed next actions; do not mark work complete while blocking gaps remain."
1320
- )
1321
- if name == "devcouncil_review_live":
1322
- scope = arguments.get("task_id")
1323
- scope_line = f" scoped to {scope}" if scope else ""
1324
- return (
1325
- f"Review the pending live-review critique cards{scope_line}.\n\n"
1326
- "1. Call `devcouncil_live_review` for the blocker count and `devcouncil_live_cards` (status='open') for the cards.\n"
1327
- "2. For each blocking card, call `devcouncil_live_repair_prompt` (or `devcouncil_live_repair_all`) to get a ready-to-apply repair.\n"
1328
- "3. Apply the fixes through the policy-gated write tools and re-verify."
1329
- )
1330
- if name == "devcouncil_project_status":
1331
- return (
1332
- "Summarize the DevCouncil project state for the user.\n\n"
1333
- f"Live snapshot: {snapshot}\n\n"
1334
- "Call `devcouncil_status` and `devcouncil_report` for the full coverage report, then give a concise "
1335
- "phase / tasks / blocking-gaps summary and recommend the next action."
1336
- )
1337
- if name == "devcouncil_apply_knowledge":
1338
- goal = arguments.get("goal") or ""
1339
- return (
1340
- f"Find and apply the project knowledge that applies to this goal: {goal!r}.\n\n"
1341
- "Call `devcouncil_select_knowledge` with the goal, then treat the returned preamble as authoritative "
1342
- "project context (design system + OKF docs) for any code you write toward this goal."
283
+ if name == "devcouncil_checkout_task":
284
+ assert db is not None
285
+ return await checkout_handlers.handle_checkout_task(
286
+ root, db, arguments, load_router=router_cache.load_router,
1343
287
  )
1344
- return f"Unknown DevCouncil prompt: {name}"
1345
-
1346
288
 
1347
- @app.list_prompts()
1348
- async def list_prompts() -> list[Prompt]:
1349
- return [
1350
- Prompt(name=spec.name, description=spec.description, arguments=list(spec.arguments))
1351
- for spec in _PROMPT_SPECS
1352
- ]
1353
-
1354
-
1355
- @app.get_prompt()
1356
- async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
1357
- spec = next((spec for spec in _PROMPT_SPECS if spec.name == name), None)
1358
- if spec is None:
1359
- raise ValueError(f"Unknown prompt: {name}")
1360
- args = _normalize_arguments(arguments)
1361
- root = _project_root()
1362
- text = _render_prompt_text(name, args, root)
1363
- return GetPromptResult(
1364
- description=spec.description,
1365
- messages=[PromptMessage(role="user", content=TextContent(type="text", text=text))],
1366
- )
1367
-
1368
-
1369
- @app.call_tool()
1370
- async def call_tool(name: str, arguments: dict) -> list[TextContent]:
1371
- arguments = _normalize_arguments(arguments)
1372
- logger.info("MCP call_tool: %s args=%s", name, sorted(arguments) if isinstance(arguments, dict) else arguments)
1373
- root = _project_root()
1374
- db = get_db(root)
1375
- if name in _DB_REQUIRED_TOOLS and not db:
1376
- logger.warning("MCP tool %s rejected: project not initialized at %s", name, root)
1377
- return _error_text("DevCouncil not initialized in this directory.", code="not_initialized")
1378
-
1379
- if name == "devcouncil_integration_status":
1380
- return _json_text(integration_status_summary(root))
1381
-
1382
- if name == "devcouncil_status":
289
+ if name == "devcouncil_release_task":
1383
290
  assert db is not None
1384
- with db.get_session() as session:
1385
- graph_repo = ArtifactGraphRepository(session)
1386
- graph = graph_repo.load_graph()
1387
- summary = graph.coverage_summary()
1388
- state = StateRepository(session).get_state()
1389
- phase = compute_phase(graph, state.current_phase if state else None)
1390
-
1391
- status_str = f"Phase: {phase}\n"
1392
- status_str += f"Requirements: {summary['total_requirements']} ({summary['requirements_without_tasks']} unmapped)\n"
1393
- status_str += f"Tasks: {summary['total_tasks']} ({summary['tasks_without_requirements']} orphaned)\n"
1394
- status_str += f"Gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking)\n"
1395
-
1396
- return [TextContent(type="text", text=status_str)]
1397
-
1398
- elif name == "devcouncil_report":
291
+ return await lease_handlers.handle_release_task(root, db, arguments)
292
+
293
+ if name == "devcouncil_renew_lease":
1399
294
  assert db is not None
1400
- with db.get_session() as session:
1401
- graph_repo = ArtifactGraphRepository(session)
1402
- graph = graph_repo.load_graph()
1403
- markdown_report = ReportBuilder.build_markdown(graph, live_review=live_review_summary(root))
1404
- return [TextContent(type="text", text=markdown_report)]
1405
-
1406
- elif name == "devcouncil_live_review":
1407
- task_id = _optional_string_argument(arguments, "task_id")
1408
- if task_id == "":
1409
- return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
1410
- return [TextContent(
1411
- type="text",
1412
- text=json.dumps(live_review_summary(root, task_id=task_id), indent=2),
1413
- )]
1414
-
1415
- elif name == "devcouncil_live_cards":
1416
- task_id = _optional_string_argument(arguments, "task_id")
1417
- status = _optional_string_argument(arguments, "status")
1418
- verdict = _optional_string_argument(arguments, "verdict")
1419
- client = _optional_string_argument(arguments, "client")
1420
- for arg_name, value in [
1421
- ("task_id", task_id),
1422
- ("status", status),
1423
- ("verdict", verdict),
1424
- ("client", client),
1425
- ]:
1426
- if value == "":
1427
- return _error_text(f"{arg_name} must be a string", code="invalid_arguments", argument=arg_name)
1428
-
1429
- limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=200)
1430
- filtered, filter_error, argument = filter_cards(
1431
- load_cards(root),
1432
- task_id=task_id,
1433
- status=status,
1434
- verdict=verdict,
1435
- client=client,
1436
- )
1437
- if filter_error:
1438
- return _error_text(filter_error, code="invalid_arguments", argument=argument)
1439
-
1440
- total = len(filtered)
1441
- return [TextContent(
1442
- type="text",
1443
- text=json.dumps({
1444
- "cards": [card.model_dump() for card in filtered[:limit]],
1445
- "filters": {
1446
- "task_id": task_id,
1447
- "status": status,
1448
- "verdict": verdict,
1449
- "client": client,
1450
- },
1451
- "limit": limit,
1452
- "total": total,
1453
- }, indent=2),
1454
- )]
1455
-
1456
- elif name == "devcouncil_live_repair_prompt":
1457
- card_id, arg_error = _required_string_argument(arguments, "card_id")
1458
- if arg_error:
1459
- return arg_error
1460
- assert card_id is not None
1461
- card = get_card(root, card_id)
1462
- if not card:
1463
- return _error_text(f"Critique card {card_id} not found.", code="not_found", card_id=card_id)
1464
- return [TextContent(
1465
- type="text",
1466
- text=json.dumps({
1467
- "card": card.model_dump(),
1468
- "prompt": build_live_repair_prompt(root, card),
1469
- }, indent=2),
1470
- )]
1471
-
1472
- elif name == "devcouncil_live_repair_all":
1473
- task_id = _optional_string_argument(arguments, "task_id")
1474
- if task_id == "":
1475
- return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
1476
- summary = live_review_summary(root, task_id=task_id)
1477
- cards = [
1478
- get_card(root, item["id"])
1479
- for item in summary["blocking_cards"]
1480
- if isinstance(item.get("id"), str)
1481
- ]
1482
- resolved_cards = [card for card in cards if card is not None]
1483
- return [TextContent(
1484
- type="text",
1485
- text=json.dumps({
1486
- "scope_task_id": summary["scope_task_id"],
1487
- "cards": [card.model_dump() for card in resolved_cards],
1488
- "prompt": build_bulk_live_repair_prompt(root, resolved_cards),
1489
- }, indent=2),
1490
- )]
1491
-
1492
- elif name == "devcouncil_get_task":
295
+ return await lease_handlers.handle_renew_lease(root, db, arguments)
296
+
297
+ if name == "devcouncil_list_leases":
1493
298
  assert db is not None
1494
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1495
- if arg_error:
1496
- return arg_error
1497
- assert task_id is not None
1498
-
1499
- with db.get_session() as session:
1500
- task_repo = TaskRepository(session)
1501
- task = task_repo.get_by_id(task_id)
1502
- if not task:
1503
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=str(task_id))
1504
-
1505
- return [TextContent(type="text", text=task.model_dump_json(indent=2))]
1506
-
1507
- elif name == "devcouncil_get_gaps":
299
+ return await lease_handlers.handle_list_leases(root, db, arguments)
300
+
301
+ if name == "devcouncil_update_task_scope":
1508
302
  assert db is not None
1509
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1510
- if arg_error:
1511
- return arg_error
1512
- assert task_id is not None # _required_string_argument returns a value when arg_error is None
1513
- blocking_only = bool(arguments.get("blocking_only", False))
1514
- with db.get_session() as session:
1515
- gaps = GapRepository(session).get_for_task(task_id)
1516
- if blocking_only:
1517
- gaps = [g for g in gaps if g.blocking]
1518
- return _json_text({
1519
- "ok": True,
1520
- "task_id": task_id,
1521
- "gaps": [g.model_dump() for g in gaps],
1522
- "blocking_count": sum(1 for g in gaps if g.blocking),
1523
- })
1524
-
1525
- elif name == "devcouncil_get_next_actions":
303
+ return await scope_handlers.handle_update_task_scope(root, db, arguments)
304
+
305
+ if name == "devcouncil_append_evidence":
1526
306
  assert db is not None
1527
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1528
- if arg_error:
1529
- return arg_error
1530
- assert task_id is not None
1531
- with db.get_session() as session:
1532
- gaps = GapRepository(session).get_for_task(task_id)
1533
- task = TaskRepository(session).get_by_id(task_id)
1534
- blocking_actions, advisory_actions = split_next_actions(gaps)
1535
- has_blocking = any(g.blocking for g in gaps)
1536
- return _json_text({
1537
- "ok": True,
1538
- "task_id": task_id,
1539
- "next_actions": [a.model_dump() for a in blocking_actions],
1540
- "advisory_actions": [a.model_dump() for a in advisory_actions],
1541
- # Self-describing loop: computed from the task's status + blocking gaps so
1542
- # the agent is steered toward what this task actually needs next.
1543
- "allowed_next_tools": _allowed_next_tools(task.status if task else "planned", has_blocking),
1544
- })
1545
-
1546
- elif name == "devcouncil_get_task_provenance":
307
+ return await evidence_handlers.handle_append_evidence(root, db, arguments)
308
+
309
+ if name == "devcouncil_record_command":
1547
310
  assert db is not None
1548
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1549
- if arg_error:
1550
- return arg_error
1551
- assert task_id is not None
1552
- with db.get_session() as session:
1553
- file_changes = [r.model_dump() for r in FileChangeRepository(session).list_for_task(task_id)]
1554
- verification_runs = [r.model_dump() for r in VerificationRunRepository(session).list_for_task(task_id)]
1555
- coverage = [
1556
- ev.model_dump()
1557
- for ev in EvidenceRepository(session).get_all()
1558
- if isinstance(ev, DiffCoverageEvidence) and ev.task_id == task_id
1559
- ]
1560
- correction_manifest = CorrectionManifestRepository(session).latest_for_task(task_id)
1561
- return _json_text({
1562
- "ok": True,
1563
- "task_id": task_id,
1564
- "file_changes": file_changes,
1565
- "verification_runs": verification_runs,
1566
- "diff_coverage": coverage,
1567
- "latest_correction_manifest": correction_manifest.model_dump() if correction_manifest else None,
1568
- })
1569
-
1570
- elif name == "devcouncil_list_tasks":
311
+ return await policy_handlers.handle_record_command(root, db, arguments)
312
+
313
+ if name == "devcouncil_write_file":
1571
314
  assert db is not None
1572
- status_filter = _optional_string_argument(arguments, "status")
1573
- if status_filter == "":
1574
- return _error_text("status must be a string", code="invalid_arguments", argument="status")
1575
- limit = _int_argument(arguments, "limit", 100, minimum=1, maximum=500)
1576
- offset = _int_argument(arguments, "offset", 0, minimum=0, maximum=1_000_000)
1577
- with db.get_session() as session:
1578
- all_tasks = TaskRepository(session).get_all()
1579
- if status_filter:
1580
- all_tasks = [t for t in all_tasks if t.status == status_filter]
1581
- total = len(all_tasks)
1582
- window = all_tasks[offset:offset + limit]
1583
- return _json_text({
1584
- "tasks": [task.model_dump() for task in window],
1585
- "total": total,
1586
- "offset": offset,
1587
- "limit": limit,
1588
- "returned": len(window),
1589
- })
1590
-
1591
- elif name == "devcouncil_get_prompt":
315
+ return await write_handlers.handle_write_file(root, db, arguments)
316
+
317
+ if name == "devcouncil_apply_patch":
1592
318
  assert db is not None
1593
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1594
- if arg_error:
1595
- return arg_error
1596
- assert task_id is not None
1597
-
1598
- with db.get_session() as session:
1599
- task_repo = TaskRepository(session)
1600
- req_repo = RequirementRepository(session)
1601
- task = task_repo.get_by_id(task_id)
1602
- if not task:
1603
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=str(task_id))
1604
- prompt = PromptBuilder(root).build_task_prompt(task, req_repo.get_all())
1605
- return [TextContent(type="text", text=prompt)]
1606
-
1607
- elif name == "devcouncil_tail_trace":
1608
- limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=200)
1609
- events = list(read_trace_events(root))[-limit:]
1610
-
1611
- return [TextContent(
1612
- type="text",
1613
- text=json.dumps({"events": [event.model_dump(by_alias=True) for event in events]}, indent=2),
1614
- )]
1615
-
1616
- elif name == "devcouncil_policy_check_write":
319
+ return await write_handlers.handle_apply_patch(root, db, arguments)
320
+
321
+ if name == "devcouncil_verify_task":
1617
322
  assert db is not None
1618
- path, arg_error = _required_string_argument(arguments, "path")
1619
- if arg_error:
1620
- return arg_error
1621
- assert path is not None
1622
- task_id = _optional_string_argument(arguments, "task_id")
1623
- if task_id == "":
1624
- return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
1625
- with db.get_session() as session:
1626
- task_repo = TaskRepository(session)
1627
- if task_id:
1628
- task = task_repo.get_by_id(task_id)
1629
- else:
1630
- running = [task for task in task_repo.get_all() if task.status == "running"]
1631
- task = running[0] if running else None
1632
- decision = HookPolicy(project_root=root).evaluate_file_write(path, task)
1633
- return [TextContent(type="text", text=json.dumps({
1634
- "action": decision.action,
1635
- "allowed": decision.allowed,
1636
- "reason": decision.reason,
1637
- "target": decision.target,
1638
- "task_id": task.id if task else None,
1639
- }, indent=2))]
1640
-
1641
- elif name == "devcouncil_graph_context":
1642
- files = arguments.get("files", [])
1643
- if not isinstance(files, list):
1644
- files = []
1645
- context = _get_graph_adapter(root).get_context([file for file in files if isinstance(file, str)])
1646
- return [TextContent(type="text", text=context.model_dump_json(indent=2))]
1647
-
1648
- elif name == "devcouncil_lsp_status":
1649
- return [TextContent(type="text", text=_get_lsp_inspector(root).summary_json())]
1650
-
1651
- elif name == "devcouncil_ast_match":
1652
- query = _optional_string_argument(arguments, "query")
1653
- language = _optional_string_argument(arguments, "language")
1654
- kind = _optional_string_argument(arguments, "kind")
1655
- for arg_name, value in [("query", query), ("language", language), ("kind", kind)]:
1656
- if value == "":
1657
- return _error_text(f"{arg_name} must be a string", code="invalid_arguments", argument=arg_name)
1658
- limit = _int_argument(arguments, "limit", 100, minimum=1, maximum=500)
1659
- matches = _get_ast_matcher(root).match(
1660
- query=query or "",
1661
- language=language,
1662
- kind=kind,
1663
- limit=limit,
323
+ return await verify_handlers.handle_verify_task(
324
+ root, db, arguments, load_router=router_cache.load_router,
1664
325
  )
1665
- return [TextContent(type="text", text=json.dumps({"matches": [item.model_dump() for item in matches]}, indent=2))]
1666
-
1667
- elif name == "devcouncil_cli":
1668
- args = arguments.get("args")
1669
- if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args) or not args:
1670
- return _error_text("args must be a non-empty string array", code="invalid_arguments")
1671
- if args[0] not in _CLI_ALLOWED_ROOTS:
1672
- return _error_text(f"command {args[0]} is not allowed through MCP", code="command_not_allowed", command=args[0])
1673
- forbidden = _forbidden_cli_flags(args)
1674
- if forbidden:
1675
- return _error_text("forbidden flag(s) through MCP: " + ", ".join(forbidden), code="forbidden_flags", flags=forbidden)
1676
- try:
1677
- return _json_text(_run_cli_command(args, root))
1678
- except Exception as exc:
1679
- return _error_text(str(exc), code="cli_execution_error")
1680
-
1681
- elif name == "devcouncil_prepare_execution":
1682
- assert db is not None
1683
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1684
- if arg_error:
1685
- return arg_error
1686
- assert task_id is not None
1687
- with db.get_session() as session:
1688
- task_repo = TaskRepository(session)
1689
- req_repo = RequirementRepository(session)
1690
- task = task_repo.get_by_id(task_id)
1691
- if not task:
1692
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=str(task_id))
1693
- prompt = PromptBuilder(root).build_task_prompt(task, req_repo.get_all())
1694
- return [TextContent(type="text", text=json.dumps({
1695
- "task_id": task.id,
1696
- "prompt": prompt,
1697
- "planned_files": [file.model_dump() for file in task.planned_files],
1698
- "allowed_commands": task.allowed_commands,
1699
- "expected_tests": task.expected_tests,
1700
- }, indent=2))]
1701
-
1702
- elif name == "devcouncil_checkout_task":
1703
- assert db is not None
1704
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1705
- if arg_error:
1706
- return arg_error
1707
- client_id, arg_error = _required_string_argument(arguments, "client_id")
1708
- if arg_error:
1709
- return arg_error
1710
- assert task_id is not None and client_id is not None
1711
- agent = _optional_string_argument(arguments, "agent")
1712
- if agent == "":
1713
- return _error_text("agent must be a string", code="invalid_arguments", argument="agent")
1714
- force_value = arguments.get("force", False)
1715
- if not isinstance(force_value, bool):
1716
- return _error_text("force must be a boolean", code="invalid_arguments", argument="force")
1717
- force = force_value
1718
- with db.get_session() as session:
1719
- task_repo = TaskRepository(session)
1720
- task = task_repo.get_by_id(task_id)
1721
- if not task:
1722
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1723
- lease_repo = TaskLeaseRepository(session)
1724
- try:
1725
- lease = lease_repo.acquire(
1726
- task_id,
1727
- owner=f"mcp:{client_id}",
1728
- agent=agent,
1729
- client_id=client_id,
1730
- ttl_seconds=_lease_ttl_seconds(root),
1731
- force=force,
1732
- )
1733
- except ValueError as exc:
1734
- return _error_text(str(exc), code="lease_conflict", task_id=task_id)
1735
- prompt = PromptBuilder(root).build_task_prompt(task, RequirementRepository(session).get_all())
1736
- semantic = None
1737
- semantic_path = root / ".devcouncil" / "semantic" / task_id / "before.json"
1738
- if semantic_path.exists():
1739
- semantic = json.loads(semantic_path.read_text(encoding="utf-8"))
1740
- return _json_text({
1741
- "ok": True,
1742
- "lease_token": lease.lease_token,
1743
- "task_id": task.id,
1744
- "status": task.status,
1745
- "expires_at": lease.expires_at,
1746
- "prompt": prompt,
1747
- "planned_files": [f.model_dump() for f in task.planned_files],
1748
- "allowed_commands": task.allowed_commands,
1749
- "expected_tests": task.expected_tests,
1750
- "semantic_context": semantic,
1751
- # The task is now leased and running-ready: surface the inner-loop tools.
1752
- "allowed_next_tools": _allowed_next_tools(
1753
- "running",
1754
- bool(GapRepository(session).get_blocking_for_task(task_id)),
1755
- ),
1756
- })
1757
-
1758
- elif name == "devcouncil_release_task":
1759
- assert db is not None
1760
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1761
- if arg_error:
1762
- return arg_error
1763
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1764
- if arg_error:
1765
- return arg_error
1766
- assert task_id is not None and lease_token is not None
1767
- with db.get_session() as session:
1768
- released = TaskLeaseRepository(session).release(task_id, lease_token)
1769
- if not released:
1770
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1771
- return _json_text({"ok": True, "task_id": task_id, "released": True})
1772
-
1773
- elif name == "devcouncil_renew_lease":
1774
- assert db is not None
1775
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1776
- if arg_error:
1777
- return arg_error
1778
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1779
- if arg_error:
1780
- return arg_error
1781
- assert task_id is not None and lease_token is not None
1782
- ttl_value = arguments.get("ttl_seconds")
1783
- if ttl_value is not None and (not isinstance(ttl_value, int) or isinstance(ttl_value, bool)):
1784
- return _error_text("ttl_seconds must be an integer", code="invalid_arguments", argument="ttl_seconds")
1785
- ttl_seconds = ttl_value if isinstance(ttl_value, int) and not isinstance(ttl_value, bool) else _lease_ttl_seconds(root)
1786
- with db.get_session() as session:
1787
- renewed_lease = TaskLeaseRepository(session).renew(task_id, lease_token, ttl_seconds)
1788
- if renewed_lease is None:
1789
- return _error_text("Invalid or expired lease.", code="invalid_lease", task_id=task_id)
1790
- return _json_text({
1791
- "ok": True,
1792
- "task_id": task_id,
1793
- "expires_at": renewed_lease.expires_at,
1794
- "ttl_seconds": ttl_seconds,
1795
- })
1796
-
1797
- elif name == "devcouncil_list_leases":
1798
- assert db is not None
1799
- active_only = arguments.get("active_only", True)
1800
- if not isinstance(active_only, bool):
1801
- return _error_text("active_only must be a boolean", code="invalid_arguments", argument="active_only")
1802
- with db.get_session() as session:
1803
- pairs = TaskLeaseRepository(session).list_leases(active_only=active_only)
1804
- leases = [
1805
- {
1806
- "task_id": lease.task_id,
1807
- "owner": lease.owner,
1808
- "agent": lease.agent,
1809
- "status": lease.status,
1810
- "expires_at": lease.expires_at,
1811
- "expired": expired,
1812
- }
1813
- for lease, expired in pairs
1814
- ]
1815
- return _json_text({"ok": True, "leases": leases, "count": len(leases)})
1816
-
1817
- elif name == "devcouncil_update_task_scope":
1818
- assert db is not None
1819
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1820
- if arg_error:
1821
- return arg_error
1822
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1823
- if arg_error:
1824
- return arg_error
1825
- assert task_id is not None and lease_token is not None
1826
- expected_tests, arg_error = _optional_string_list_argument(arguments, "expected_tests")
1827
- if arg_error:
1828
- return arg_error
1829
- allowed_commands, arg_error = _optional_string_list_argument(arguments, "allowed_commands")
1830
- if arg_error:
1831
- return arg_error
1832
- with db.get_session() as session:
1833
- lease_repo = TaskLeaseRepository(session)
1834
- if not lease_repo.validate(task_id, lease_token):
1835
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1836
- task_repo = TaskRepository(session)
1837
- task = task_repo.get_by_id(task_id)
1838
- if not task:
1839
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1840
- for cmd in allowed_commands:
1841
- if cmd not in task.allowed_commands:
1842
- task.allowed_commands.append(cmd)
1843
- for test in expected_tests:
1844
- if test not in task.expected_tests:
1845
- task.expected_tests.append(test)
1846
- task_repo.save(task)
1847
- return _json_text({
1848
- "ok": True,
1849
- "task_id": task_id,
1850
- "allowed_commands": task.allowed_commands,
1851
- "expected_tests": task.expected_tests,
1852
- })
1853
-
1854
- elif name == "devcouncil_append_evidence":
1855
- assert db is not None
1856
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1857
- if arg_error:
1858
- return arg_error
1859
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1860
- if arg_error:
1861
- return arg_error
1862
- command, arg_error = _required_string_argument(arguments, "command")
1863
- if arg_error:
1864
- return arg_error
1865
- summary_text, arg_error = _required_string_argument(arguments, "summary")
1866
- if arg_error:
1867
- return arg_error
1868
- assert task_id is not None and lease_token is not None
1869
- exit_code = arguments.get("exit_code", 0)
1870
- if not isinstance(exit_code, int) or isinstance(exit_code, bool):
1871
- return _error_text("exit_code must be an integer", code="invalid_arguments")
1872
- with db.get_session() as session:
1873
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
1874
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1875
- EvidenceRepository(session).save_command_result(
1876
- task_id,
1877
- CommandResult(
1878
- command=command or "",
1879
- exit_code=exit_code,
1880
- stdout_path="",
1881
- stderr_path="",
1882
- summary=summary_text or "",
1883
- ),
1884
- )
1885
- return _json_text({"ok": True, "task_id": task_id, "recorded": True})
1886
326
 
1887
- elif name == "devcouncil_record_command":
327
+ if name == "devcouncil_handoff_agent":
1888
328
  assert db is not None
1889
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1890
- if arg_error:
1891
- return arg_error
1892
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1893
- if arg_error:
1894
- return arg_error
1895
- command, arg_error = _required_string_argument(arguments, "command")
1896
- if arg_error:
1897
- return arg_error
1898
- status, arg_error = _required_string_argument(arguments, "status")
1899
- if arg_error:
1900
- return arg_error
1901
- assert task_id is not None and lease_token is not None
1902
- if status not in _RECORD_COMMAND_STATUSES:
1903
- return _error_text(
1904
- f"status must be one of {sorted(_RECORD_COMMAND_STATUSES)}",
1905
- code="invalid_arguments", argument="status",
1906
- )
1907
- with db.get_session() as session:
1908
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
1909
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1910
- exit_code = arguments.get("exit_code")
1911
- if exit_code is not None and (not isinstance(exit_code, int) or isinstance(exit_code, bool)):
1912
- return _error_text("exit_code must be an integer", code="invalid_arguments")
1913
- ShellCommandRepository(session).record(
1914
- task_id,
1915
- command or "",
1916
- status or "finished",
1917
- exit_code=exit_code if isinstance(exit_code, int) else None,
1918
- reason=str(arguments.get("reason") or ""),
1919
- )
1920
- return _json_text({"ok": True, "task_id": task_id, "recorded": True})
329
+ return await handoff_handlers.handle_handoff_agent(root, db, arguments)
1921
330
 
1922
- elif name == "devcouncil_write_file":
1923
- assert db is not None
1924
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1925
- if arg_error:
1926
- return arg_error
1927
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1928
- if arg_error:
1929
- return arg_error
1930
- rel_path, arg_error = _required_string_argument(arguments, "path")
1931
- if arg_error:
1932
- return arg_error
1933
- content = arguments.get("content")
1934
- if not isinstance(content, str):
1935
- return _error_text("content must be a string", code="invalid_arguments", argument="content")
1936
- assert task_id is not None and lease_token is not None and rel_path is not None
1937
- with db.get_session() as session:
1938
- lease_record = TaskLeaseRepository(session).active_for_task(task_id)
1939
- if lease_record is None or lease_record.lease_token != lease_token:
1940
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
1941
- task = TaskRepository(session).get_by_id(task_id)
1942
- if not task:
1943
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
1944
-
1945
- decision = HookPolicy(project_root=root).evaluate_file_write(rel_path, task, content=content)
1946
- target = _within_root(root, rel_path)
1947
- if target is None:
1948
- FileChangeRepository(session).record(
1949
- rel_path, "write", False, task_id=task_id, lease_id=lease_record.id,
1950
- reason="path escapes the project root",
1951
- )
1952
- return _json_text({
1953
- "ok": False, "task_id": task_id, "applied_files": [],
1954
- "rejected_files": [{"path": rel_path, "reason": "path escapes the project root"}],
1955
- })
1956
- if not decision.allowed:
1957
- FileChangeRepository(session).record(
1958
- rel_path, "write", False, task_id=task_id, lease_id=lease_record.id, reason=decision.reason,
1959
- )
1960
- return _json_text({
1961
- "ok": False, "task_id": task_id, "applied_files": [],
1962
- "rejected_files": [{"path": rel_path, "reason": decision.reason}],
1963
- })
1964
- # Atomic write: stage to a sibling temp file, then replace.
1965
- try:
1966
- target.parent.mkdir(parents=True, exist_ok=True)
1967
- tmp = target.with_name(target.name + ".devcouncil-tmp")
1968
- tmp.write_text(content, encoding="utf-8")
1969
- os.replace(tmp, target)
1970
- except OSError as exc:
1971
- return _error_text(f"Write failed: {exc}", code="write_failed", task_id=task_id)
1972
- FileChangeRepository(session).record(
1973
- rel_path, "write", True, task_id=task_id, lease_id=lease_record.id, reason=decision.reason,
1974
- )
1975
- return _json_text({
1976
- "ok": True, "task_id": task_id, "applied_files": [rel_path], "rejected_files": [],
1977
- })
331
+ if name == "devcouncil_read_file":
332
+ return await read_handlers.handle_read_file(root, arguments)
1978
333
 
1979
- elif name == "devcouncil_apply_patch":
1980
- assert db is not None
1981
- task_id, arg_error = _required_string_argument(arguments, "task_id")
1982
- if arg_error:
1983
- return arg_error
1984
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
1985
- if arg_error:
1986
- return arg_error
1987
- unified_diff = arguments.get("unified_diff")
1988
- if not isinstance(unified_diff, str) or not unified_diff.strip():
1989
- return _error_text("unified_diff must be a non-empty string", code="invalid_arguments", argument="unified_diff")
1990
- assert task_id is not None and lease_token is not None
1991
- if not _is_git_repo(root):
1992
- return _error_text(
1993
- "apply_patch requires a git repository. Use devcouncil_write_file instead.",
1994
- code="not_a_git_repo", task_id=task_id,
1995
- )
1996
- targets = _diff_target_paths(unified_diff)
1997
- if not targets:
1998
- return _error_text("No target files found in the diff.", code="empty_patch", task_id=task_id)
1999
- with db.get_session() as session:
2000
- lease_record = TaskLeaseRepository(session).active_for_task(task_id)
2001
- if lease_record is None or lease_record.lease_token != lease_token:
2002
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
2003
- task = TaskRepository(session).get_by_id(task_id)
2004
- if not task:
2005
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
2006
-
2007
- # Policy-check EVERY target before touching the tree. Any rejection aborts
2008
- # the whole patch — never a partial apply.
2009
- policy = HookPolicy(project_root=root)
2010
- rejected: list[dict[str, str]] = []
2011
- for path in targets:
2012
- if _within_root(root, path) is None:
2013
- rejected.append({"path": path, "reason": "path escapes the project root"})
2014
- continue
2015
- d = policy.evaluate_file_write(path, task)
2016
- if not d.allowed:
2017
- rejected.append({"path": path, "reason": d.reason})
2018
- if rejected:
2019
- for item in rejected:
2020
- FileChangeRepository(session).record(
2021
- item["path"], "apply_patch", False, task_id=task_id, lease_id=lease_record.id, reason=item["reason"],
2022
- )
2023
- return _json_text({
2024
- "ok": False, "task_id": task_id, "applied_files": [], "rejected_files": rejected,
2025
- })
2026
-
2027
- # Validate then apply atomically (git apply is all-or-nothing).
2028
- patch_path = root / ".devcouncil" / f"mcp-apply-{lease_record.id}.patch"
2029
- patch_path.parent.mkdir(parents=True, exist_ok=True)
2030
- patch_path.write_text(unified_diff, encoding="utf-8")
2031
- try:
2032
- check = subprocess.run(
2033
- ["git", "apply", "--check", "--ignore-whitespace", str(patch_path)],
2034
- cwd=root, capture_output=True, text=True,
2035
- )
2036
- if check.returncode != 0:
2037
- return _error_text(
2038
- f"Patch does not apply cleanly: {check.stderr.strip()}",
2039
- code="patch_rejected", task_id=task_id,
2040
- )
2041
- applied = subprocess.run(
2042
- ["git", "apply", "--ignore-whitespace", str(patch_path)],
2043
- cwd=root, capture_output=True, text=True,
2044
- )
2045
- if applied.returncode != 0:
2046
- return _error_text(
2047
- f"Patch apply failed: {applied.stderr.strip()}",
2048
- code="patch_failed", task_id=task_id,
2049
- )
2050
- finally:
2051
- try:
2052
- patch_path.unlink()
2053
- except OSError:
2054
- pass
2055
- for path in targets:
2056
- FileChangeRepository(session).record(
2057
- path, "apply_patch", True, task_id=task_id, lease_id=lease_record.id, reason="policy allowed",
2058
- )
2059
- return _json_text({
2060
- "ok": True, "task_id": task_id, "applied_files": targets, "rejected_files": [],
2061
- })
2062
-
2063
- elif name == "devcouncil_verify_task":
2064
- assert db is not None
2065
- task_id, arg_error = _required_string_argument(arguments, "task_id")
2066
- if arg_error:
2067
- return arg_error
2068
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
2069
- if arg_error:
2070
- return arg_error
2071
- assert task_id is not None and lease_token is not None
2072
- sandbox = _optional_string_argument(arguments, "sandbox") or "local"
2073
- if sandbox in {"docker", "nix"}:
2074
- return _json_text({
2075
- "ok": False,
2076
- "code": "unsupported_sandbox",
2077
- "reason": f"Sandbox {sandbox} is not available in this build.",
2078
- "sandbox": sandbox,
2079
- })
2080
- with db.get_session() as session:
2081
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
2082
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
2083
- task_repo = TaskRepository(session)
2084
- task = task_repo.get_by_id(task_id)
2085
- if not task:
2086
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
2087
- from devcouncil.verification.verifier import Verifier
2088
-
2089
- GapRepository(session).delete_for_task(task_id)
2090
- EvidenceRepository(session).delete_for_task(task_id)
2091
- # Run the STRONG gate when a provider key is configured (compiled
2092
- # per-criterion checks); otherwise fall back to coarse mode and report it.
2093
- verifier = Verifier(root, router=_load_router(root))
2094
- evidence_gaps, evidence = await verifier.verify_task(
2095
- task, RequirementRepository(session).get_all()
2096
- )
2097
- gaps = evidence_gaps
2098
- for gap in gaps:
2099
- GapRepository(session).save(gap)
2100
- for ev in evidence:
2101
- if isinstance(ev, CommandResult):
2102
- EvidenceRepository(session).save_command_result(task_id, ev)
2103
- elif isinstance(ev, DiffCoverageEvidence):
2104
- EvidenceRepository(session).save_diff_coverage_evidence(ev)
2105
- elif isinstance(ev, DiffEvidence):
2106
- EvidenceRepository(session).save_diff_evidence(ev)
2107
- elif isinstance(ev, TestEvidence):
2108
- EvidenceRepository(session).save_test_evidence(ev, task_id)
2109
- task.status = "blocked" if any(g.blocking for g in gaps) else "verified"
2110
- task_repo.save(task)
2111
- blocking = [g.model_dump() for g in gaps if g.blocking]
2112
- # The typed next-actions contract: structured, routable steps the agent
2113
- # can act on to self-repair and re-verify without a human pasting prose.
2114
- # blocking_actions must be cleared to pass; advisory_actions (e.g. the
2115
- # diff↔coverage "tests passed but new code never ran" signal) are quality
2116
- # signals the agent can act on without confusing them with the gate.
2117
- blocking_actions, advisory_actions = split_next_actions(gaps)
2118
- outcome = verifier.last_outcome
2119
- return _json_text({
2120
- "ok": True,
2121
- "task_id": task_id,
2122
- "status": task.status,
2123
- "sandbox": sandbox,
2124
- "blocking_gaps": blocking,
2125
- "next_actions": [a.model_dump() for a in blocking_actions],
2126
- "advisory_actions": [a.model_dump() for a in advisory_actions],
2127
- # Computed from the post-verify status: verified -> release only,
2128
- # blocked -> the read/edit/test repair loop.
2129
- "allowed_next_tools": _allowed_next_tools(task.status, len(blocking) > 0),
2130
- "passed": len(blocking) == 0,
2131
- # Rigor of this run so the agent never reads passed==True as proven
2132
- # when the gate could not actually check.
2133
- "verification_mode": outcome.mode if outcome else "unknown",
2134
- "compiler_active": outcome.compiler_active if outcome else False,
2135
- "diff_empty": outcome.diff_empty if outcome else False,
2136
- "coverage_measured": outcome.coverage_measured if outcome else False,
2137
- "coverage_skipped_reason": outcome.coverage_skipped_reason if outcome else None,
2138
- })
2139
-
2140
- elif name == "devcouncil_handoff_agent":
2141
- assert db is not None
2142
- task_id, arg_error = _required_string_argument(arguments, "task_id")
2143
- if arg_error:
2144
- return arg_error
2145
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
2146
- if arg_error:
2147
- return arg_error
2148
- from_agent, arg_error = _required_string_argument(arguments, "from_agent")
2149
- if arg_error:
2150
- return arg_error
2151
- to_agent, arg_error = _required_string_argument(arguments, "to_agent")
2152
- if arg_error:
2153
- return arg_error
2154
- assert task_id is not None and lease_token is not None
2155
- with db.get_session() as session:
2156
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
2157
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
2158
- try:
2159
- from devcouncil.execution.handoff import HandoffService
2160
-
2161
- manifest, handoff_path, run_id = HandoffService(root).create(
2162
- task_id,
2163
- from_agent or "",
2164
- to_agent or "",
2165
- instruction=str(arguments.get("instruction") or ""),
2166
- )
2167
- return _json_text({
2168
- "ok": True,
2169
- "task_id": task_id,
2170
- "manifest_path": str(handoff_path),
2171
- "run_id": run_id,
2172
- "manifest": manifest.model_dump(),
2173
- })
2174
- except ValueError as exc:
2175
- return _error_text(str(exc), code="handoff_failed", task_id=task_id)
2176
-
2177
- elif name == "devcouncil_read_file":
2178
- rel_path, arg_error = _required_string_argument(arguments, "path")
2179
- if arg_error:
2180
- return arg_error
2181
- assert rel_path is not None
2182
- if _is_secret_path(root, rel_path):
2183
- return _error_text(
2184
- "Refusing to read a secret/credential path.",
2185
- code="secret_path", path=rel_path,
2186
- )
2187
- target = _within_root(root, rel_path)
2188
- if target is None:
2189
- return _error_text("path escapes the project root", code="path_escape", path=rel_path)
2190
- if not target.exists() or not target.is_file():
2191
- return _error_text(f"File not found: {rel_path}", code="not_found", path=rel_path)
2192
- try:
2193
- raw = target.read_bytes()
2194
- except OSError as exc:
2195
- return _error_text(f"Read failed: {exc}", code="read_failed", path=rel_path)
2196
- sha256 = hashlib.sha256(raw).hexdigest()
2197
- text = raw.decode("utf-8", errors="replace")
2198
- all_lines = text.splitlines()
2199
- line_count = len(all_lines)
2200
- # Optional windowing: line_range ('10-40', 1-based inclusive) wins over offset/limit.
2201
- line_range = _optional_string_argument(arguments, "line_range")
2202
- if line_range == "":
2203
- return _error_text("line_range must be a string", code="invalid_arguments", argument="line_range")
2204
- selected = all_lines
2205
- if line_range:
2206
- try:
2207
- start_str, _, end_str = line_range.partition("-")
2208
- start = max(1, int(start_str))
2209
- end = int(end_str) if end_str else line_count
2210
- except ValueError:
2211
- return _error_text("line_range must look like '10-40'", code="invalid_arguments", argument="line_range")
2212
- selected = all_lines[start - 1:end]
2213
- else:
2214
- offset = _int_argument(arguments, "offset", 0, minimum=0, maximum=10_000_000)
2215
- limit_value = arguments.get("limit")
2216
- if isinstance(limit_value, int) and not isinstance(limit_value, bool):
2217
- limit = max(1, limit_value)
2218
- selected = all_lines[offset:offset + limit]
2219
- elif offset:
2220
- selected = all_lines[offset:]
2221
- windowed = "\n".join(selected)
2222
- content, truncated = _truncate_text(windowed)
2223
- return _json_text({
2224
- "ok": True,
2225
- "path": rel_path.replace("\\", "/"),
2226
- "content": content,
2227
- "sha256": sha256,
2228
- "line_count": line_count,
2229
- "truncated": truncated,
2230
- })
2231
-
2232
- elif name == "devcouncil_get_diff":
2233
- if not _is_git_repo(root):
2234
- return _error_text("get_diff requires a git repository.", code="not_a_git_repo")
2235
- task_id = _optional_string_argument(arguments, "task_id")
2236
- if task_id == "":
2237
- return _error_text("task_id must be a string", code="invalid_arguments", argument="task_id")
2238
- explicit_paths, arg_error = _optional_string_list_argument(arguments, "paths")
2239
- if arg_error:
2240
- return arg_error
2241
- staged_value = arguments.get("staged", False)
2242
- if not isinstance(staged_value, bool):
2243
- return _error_text("staged must be a boolean", code="invalid_arguments", argument="staged")
2244
- scope_paths: list[str] = list(explicit_paths)
2245
- if task_id and db:
2246
- with db.get_session() as session:
2247
- task = TaskRepository(session).get_by_id(task_id)
2248
- if task is None:
2249
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
2250
- for planned in task.planned_files:
2251
- p = planned.path.replace("\\", "/")
2252
- if p not in scope_paths:
2253
- scope_paths.append(p)
2254
- return _json_text(await _git_diff(root, scope_paths, staged_value))
2255
-
2256
- elif name == "devcouncil_get_evidence":
334
+ if name == "devcouncil_get_diff":
335
+ return await git_handlers.handle_get_diff(root, db, arguments)
336
+
337
+ if name == "devcouncil_get_evidence":
2257
338
  assert db is not None
2258
- task_id, arg_error = _required_string_argument(arguments, "task_id")
2259
- if arg_error:
2260
- return arg_error
2261
- assert task_id is not None
2262
- command_filter = _optional_string_argument(arguments, "command")
2263
- if command_filter == "":
2264
- return _error_text("command must be a string", code="invalid_arguments", argument="command")
2265
- limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=100)
2266
- with db.get_session() as session:
2267
- results = EvidenceRepository(session).get_command_results_for_task(task_id)
2268
- evidence_rows: list[dict[str, object]] = []
2269
- for result in results:
2270
- if command_filter and command_filter not in result.command:
2271
- continue
2272
- stdout, stdout_truncated = _truncate_text(_read_log_file(result.stdout_path))
2273
- stderr, stderr_truncated = _truncate_text(_read_log_file(result.stderr_path))
2274
- evidence_rows.append({
2275
- "command": result.command,
2276
- "exit_code": result.exit_code,
2277
- "summary": result.summary,
2278
- "stdout": stdout,
2279
- "stderr": stderr,
2280
- "truncated": stdout_truncated or stderr_truncated,
2281
- })
2282
- if len(evidence_rows) >= limit:
2283
- break
2284
- return _json_text({"ok": True, "task_id": task_id, "evidence": evidence_rows})
2285
-
2286
- elif name == "devcouncil_run_command":
339
+ return await evidence_handlers.handle_get_evidence(root, db, arguments)
340
+
341
+ if name == "devcouncil_run_command":
2287
342
  assert db is not None
2288
- task_id, arg_error = _required_string_argument(arguments, "task_id")
2289
- if arg_error:
2290
- return arg_error
2291
- lease_token, arg_error = _required_string_argument(arguments, "lease_token")
2292
- if arg_error:
2293
- return arg_error
2294
- command, arg_error = _required_string_argument(arguments, "command")
2295
- if arg_error:
2296
- return arg_error
2297
- assert task_id is not None and lease_token is not None and command is not None
2298
- normalized = " ".join(command.split())
2299
- with db.get_session() as session:
2300
- if not TaskLeaseRepository(session).validate(task_id, lease_token):
2301
- return _error_text("Invalid lease token.", code="invalid_lease", task_id=task_id)
2302
- task = TaskRepository(session).get_by_id(task_id)
2303
- if not task:
2304
- return _error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
2305
- from devcouncil.execution.policy_engine import TaskPolicyEngine
2306
-
2307
- policy_decision = TaskPolicyEngine(root).evaluate_command(normalized, task)
2308
- if policy_decision.action == "deny":
2309
- # Record nothing executed; the gate refused before any side effect.
2310
- ShellCommandRepository(session).record(
2311
- task_id, normalized, "blocked", reason=policy_decision.reason,
2312
- )
2313
- return _error_text(
2314
- policy_decision.reason or "Command is not in the task allowlist.",
2315
- code="command_not_allowed", task_id=task_id, command=normalized,
2316
- )
2317
- try:
2318
- import shlex
2319
-
2320
- args = shlex.split(normalized, posix=(os.name != "nt"))
2321
- completed = subprocess.run(
2322
- args,
2323
- cwd=root,
2324
- capture_output=True,
2325
- text=True,
2326
- encoding="utf-8",
2327
- errors="replace",
2328
- env=clean_subprocess_env(),
2329
- timeout=_CLI_TIMEOUT_SECONDS,
2330
- )
2331
- exit_code = completed.returncode
2332
- stdout, stdout_truncated = _truncate_text(completed.stdout)
2333
- stderr, stderr_truncated = _truncate_text(completed.stderr)
2334
- timed_out = False
2335
- except subprocess.TimeoutExpired as exc:
2336
- exit_code = None
2337
- stdout, stdout_truncated = _truncate_text(exc.output)
2338
- stderr, stderr_truncated = _truncate_text(exc.stderr)
2339
- timed_out = True
2340
- except (FileNotFoundError, OSError, ValueError) as exc:
2341
- ShellCommandRepository(session).record(
2342
- task_id, normalized, "failed", reason=str(exc),
2343
- )
2344
- return _error_text(f"Could not run command: {exc}", code="run_failed", task_id=task_id)
2345
- ShellCommandRepository(session).record(
2346
- task_id,
2347
- normalized,
2348
- "finished" if exit_code == 0 else "failed",
2349
- exit_code=exit_code,
2350
- )
2351
- return _json_text({
2352
- "ok": exit_code == 0,
2353
- "task_id": task_id,
2354
- "exit_code": exit_code,
2355
- "stdout": stdout,
2356
- "stderr": stderr,
2357
- "truncated": stdout_truncated or stderr_truncated,
2358
- "timed_out": timed_out,
2359
- })
2360
-
2361
- elif name == "devcouncil_next_task":
343
+ return await run_handlers.handle_run_command(root, db, arguments)
344
+
345
+ if name == "devcouncil_next_task":
2362
346
  assert db is not None
2363
- status_filter = _optional_string_argument(arguments, "status")
2364
- if status_filter == "":
2365
- return _error_text("status must be a string", code="invalid_arguments", argument="status")
2366
- client_id = _optional_string_argument(arguments, "client_id")
2367
- if client_id == "":
2368
- return _error_text("client_id must be a string", code="invalid_arguments", argument="client_id")
2369
- with db.get_session() as session:
2370
- tasks = TaskRepository(session).get_all()
2371
- leased_task_ids = {
2372
- lease.task_id
2373
- for lease, expired in TaskLeaseRepository(session).list_leases(active_only=True)
2374
- if not expired
2375
- }
2376
- blocking_by_task: dict[str, int] = {}
2377
- for gap in GapRepository(session).get_all():
2378
- if gap.blocking and gap.task_id:
2379
- blocking_by_task[gap.task_id] = blocking_by_task.get(gap.task_id, 0) + 1
2380
- done_ids = {t.id for t in tasks if t.status in {"verified", "done"}}
2381
- # Candidate set: not finished, not actively leased, deps satisfied, matching the
2382
- # optional status filter (default to planned/ready bootstrap states).
2383
- wanted_statuses = {status_filter} if status_filter else {"planned", "ready"}
2384
- candidates = []
2385
- for task in tasks:
2386
- if task.status not in wanted_statuses:
2387
- continue
2388
- if task.id in leased_task_ids:
2389
- continue
2390
- if any(dep not in done_ids for dep in task.depends_on):
2391
- continue
2392
- candidates.append(task)
2393
- if not candidates:
2394
- return _json_text({
2395
- "ok": True,
2396
- "task": None,
2397
- "reason": "No unblocked, unleased task is available.",
2398
- })
2399
- # Deterministic "highest priority": fewest unmet deps, then task id order, so the
2400
- # same task is chosen on every call (no race with list_tasks ordering).
2401
- candidates.sort(key=lambda t: (len(t.depends_on), t.id))
2402
- chosen = candidates[0]
2403
- blocking_count = blocking_by_task.get(chosen.id, 0)
2404
- return _json_text({
2405
- "ok": True,
2406
- "task": chosen.model_dump(),
2407
- "blocking_gap_count": blocking_count,
2408
- "ready_to_checkout": blocking_count == 0,
2409
- "allowed_next_tools": _allowed_next_tools(chosen.status, blocking_count > 0),
2410
- })
2411
-
2412
- elif name == "devcouncil_list_agent_runs":
2413
- from devcouncil.cli.commands.runs import _collect_runs, _orphan_after_seconds
2414
-
2415
- status_filter = _optional_string_argument(arguments, "status")
2416
- if status_filter == "":
2417
- return _error_text("status must be a string", code="invalid_arguments", argument="status")
2418
- limit = _int_argument(arguments, "limit", 20, minimum=1, maximum=500)
2419
- run_rows = _collect_runs(root, orphan_after=_orphan_after_seconds(root))
2420
- if status_filter:
2421
- run_rows = [row for row in run_rows if row.get("status") == status_filter]
2422
- total = len(run_rows)
2423
- run_window = run_rows[:limit]
2424
- return _json_text({"ok": True, "runs": run_window, "total": total, "returned": len(run_window)})
2425
-
2426
- elif name == "devcouncil_get_run":
2427
- from devcouncil.cli.commands.runs import (
2428
- _find_transcript,
2429
- _is_orphaned,
2430
- _load_manifest,
2431
- _orphan_after_seconds,
2432
- _runs_dir,
2433
- _transcript_tail,
2434
- )
2435
- import time as _time
2436
-
2437
- target_run_id, arg_error = _required_string_argument(arguments, "run_id")
2438
- if arg_error:
2439
- return arg_error
2440
- assert target_run_id is not None
2441
- run_dir = _runs_dir(root) / target_run_id
2442
- manifest_path = run_dir / "agent-run.json"
2443
- run_manifest = _load_manifest(manifest_path)
2444
- if run_manifest is None:
2445
- return _error_text(f"Run {target_run_id} not found.", code="not_found", run_id=target_run_id)
2446
- orphaned = _is_orphaned(
2447
- run_manifest, manifest_path, orphan_after=_orphan_after_seconds(root), now=_time.time()
2448
- )
2449
- transcript_path = _find_transcript(run_dir, run_manifest)
2450
- transcript_tail = _transcript_tail(transcript_path) if transcript_path else ""
2451
- tail, truncated = _truncate_text(transcript_tail)
2452
- return _json_text({
2453
- "ok": True,
2454
- "run_id": target_run_id,
2455
- "manifest": run_manifest,
2456
- "orphaned": orphaned,
2457
- "transcript_path": str(transcript_path) if transcript_path else None,
2458
- "transcript_tail": tail,
2459
- "transcript_truncated": truncated,
2460
- })
2461
-
2462
- elif name == "devcouncil_select_knowledge":
2463
- # No DB needed: knowledge lives on disk. Best-effort — a knowledge failure
2464
- # degrades to an empty preamble rather than crashing the server.
2465
- goal, arg_error = _required_string_argument(arguments, "goal")
2466
- if arg_error:
2467
- return arg_error
2468
- assert goal is not None
2469
- try:
2470
- from devcouncil.knowledge.sources import (
2471
- render_knowledge_preamble,
2472
- select_knowledge_sources,
2473
- )
347
+ return await next_task_handlers.handle_next_task(root, db, arguments)
348
+
349
+ if name == "devcouncil_list_agent_runs":
350
+ return await runs_handlers.handle_list_agent_runs(root, arguments)
351
+
352
+ if name == "devcouncil_get_run":
353
+ return await runs_handlers.handle_get_run(root, arguments)
354
+
355
+ if name == "devcouncil_select_knowledge":
356
+ return await knowledge_handlers.handle_select_knowledge(root, arguments)
357
+
358
+ if name == "devcouncil_wiki_page":
359
+ return await wiki_handlers.handle_wiki_page(root, arguments)
2474
360
 
2475
- # Honor the project's knowledge config so MCP selection matches the prompts.
2476
- directory, design_always = _knowledge_settings(root)
2477
- if directory is None: # explicitly disabled
2478
- sources = []
2479
- else:
2480
- sources = select_knowledge_sources(
2481
- goal, root, directory=directory, design_always=design_always
2482
- )
2483
- preamble = render_knowledge_preamble(sources)
2484
- return _json_text({
2485
- "ok": True,
2486
- "goal": goal,
2487
- "sources": [
2488
- {"name": s.name, "kind": s.kind, "description": s.description}
2489
- for s in sources
2490
- ],
2491
- "preamble": preamble,
2492
- })
2493
- except Exception as exc:
2494
- return _json_text({
2495
- "ok": True,
2496
- "goal": goal,
2497
- "sources": [],
2498
- "preamble": "",
2499
- "note": f"knowledge unavailable: {exc}",
2500
- })
361
+ if name == "devcouncil_run_timeline":
362
+ return await trace_handlers.handle_run_timeline(root, arguments)
363
+
364
+ if name == "devcouncil_run_supervise":
365
+ return await trace_handlers.handle_run_supervise(
366
+ root, arguments, load_router=router_cache.load_router,
367
+ )
2501
368
 
2502
369
  logger.warning("MCP unknown tool requested: %s", name)
2503
370
  return _error_text(f"Unknown tool: {name}", code="unknown_tool", tool=name)
2504
371
 
372
+
2505
373
  async def run():
2506
- # Use stdio to communicate
374
+ from devcouncil.telemetry.logging_setup import configure_logging, set_log_dir
375
+
376
+ configure_logging()
377
+ root = Path(os.environ.get("DEVCOUNCIL_PROJECT_ROOT", ".")).expanduser().resolve()
378
+ set_log_dir(root)
379
+ logger.info("MCP server starting (project_root=%s)", root)
2507
380
  async with stdio_server() as (read_stream, write_stream):
2508
381
  await app.run(read_stream, write_stream, app.create_initialization_options())
2509
382
 
383
+
2510
384
  if __name__ == "__main__":
2511
385
  asyncio.run(run())