devcouncil 0.3.1 → 0.4.1

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