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