devcouncil 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (365) hide show
  1. package/README.md +46 -30
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +49 -30
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +960 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +303 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/tasks.py +12 -3
  255. package/src/devcouncil/live/transcripts.py +69 -2
  256. package/src/devcouncil/llm/cache.py +5 -6
  257. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  258. package/src/devcouncil/llm/provider.py +647 -73
  259. package/src/devcouncil/llm/router.py +271 -46
  260. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  261. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  262. package/src/devcouncil/optimization/skillopt.py +9 -5
  263. package/src/devcouncil/planning/arbiter_service.py +12 -3
  264. package/src/devcouncil/planning/correction_manifest.py +107 -10
  265. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  266. package/src/devcouncil/planning/plan_service.py +5 -2
  267. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  268. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  269. package/src/devcouncil/planning/question_conversion.py +56 -0
  270. package/src/devcouncil/planning/spec_service.py +9 -3
  271. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  272. package/src/devcouncil/repo/gitignore.py +1 -2
  273. package/src/devcouncil/reporting/evidence_export.py +124 -0
  274. package/src/devcouncil/reporting/evidence_html.py +210 -0
  275. package/src/devcouncil/reporting/json_report.py +16 -12
  276. package/src/devcouncil/reporting/markdown_report.py +38 -9
  277. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  278. package/src/devcouncil/reporting/report_builder.py +40 -4
  279. package/src/devcouncil/reporting/task_provenance.py +42 -0
  280. package/src/devcouncil/reporting/verdict.py +75 -0
  281. package/src/devcouncil/skills/library/README.md +1 -0
  282. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  283. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  285. package/src/devcouncil/skills/registry.py +43 -12
  286. package/src/devcouncil/storage/db.py +57 -11
  287. package/src/devcouncil/storage/models.py +6 -0
  288. package/src/devcouncil/storage/native.py +5 -3
  289. package/src/devcouncil/storage/repositories.py +50 -18
  290. package/src/devcouncil/telemetry/context.py +28 -0
  291. package/src/devcouncil/telemetry/cost.py +4 -5
  292. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  293. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  294. package/src/devcouncil/telemetry/stages.py +27 -2
  295. package/src/devcouncil/telemetry/tracker.py +50 -13
  296. package/src/devcouncil/ui/dashboard.py +120 -8
  297. package/src/devcouncil/utils/fsio.py +58 -0
  298. package/src/devcouncil/utils/git_snapshot.py +112 -0
  299. package/src/devcouncil/utils/json_persist.py +53 -0
  300. package/src/devcouncil/utils/proc.py +89 -0
  301. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  302. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  303. package/src/devcouncil/verification/checks/__init__.py +41 -0
  304. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  305. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  306. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  307. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  308. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  309. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  310. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  311. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  312. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  313. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  314. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  315. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  316. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  317. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  318. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  319. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  320. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  321. package/src/devcouncil/verification/checks/wiring.py +216 -0
  322. package/src/devcouncil/verification/claims/__init__.py +23 -0
  323. package/src/devcouncil/verification/claims/checks.py +395 -0
  324. package/src/devcouncil/verification/claims/mapper.py +168 -0
  325. package/src/devcouncil/verification/claims/models.py +39 -0
  326. package/src/devcouncil/verification/claims/transcript.py +92 -0
  327. package/src/devcouncil/verification/claims/verdict.py +88 -0
  328. package/src/devcouncil/verification/command_evidence.py +170 -0
  329. package/src/devcouncil/verification/command_malformation.py +147 -0
  330. package/src/devcouncil/verification/command_runner.py +164 -0
  331. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  332. package/src/devcouncil/verification/diff_coverage.py +151 -0
  333. package/src/devcouncil/verification/difficulty.py +296 -0
  334. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  335. package/src/devcouncil/verification/gap_ids.py +63 -0
  336. package/src/devcouncil/verification/gate_cache.py +194 -0
  337. package/src/devcouncil/verification/gate_selector.py +344 -0
  338. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  339. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  340. package/src/devcouncil/verification/incremental_check.py +241 -0
  341. package/src/devcouncil/verification/next_actions.py +60 -1
  342. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  343. package/src/devcouncil/verification/sandbox.py +38 -11
  344. package/src/devcouncil/verification/stub_detector.py +369 -0
  345. package/src/devcouncil/verification/test_resolver.py +67 -1
  346. package/src/devcouncil/verification/verifier.py +137 -1666
  347. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  348. package/src/devcouncil/verification/verify_setup.py +176 -0
  349. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  350. package/src/semantic_layer/__init__.py +58 -0
  351. package/src/semantic_layer/benchmark.py +75 -0
  352. package/src/semantic_layer/cache.py +290 -0
  353. package/src/semantic_layer/compressor.py +137 -0
  354. package/src/semantic_layer/config.py +75 -0
  355. package/src/semantic_layer/embeddings.py +69 -0
  356. package/src/semantic_layer/llm_backends.py +99 -0
  357. package/src/semantic_layer/pipeline.py +111 -0
  358. package/src/semantic_layer/router.py +128 -0
  359. package/src/semantic_layer/tuner.py +72 -0
  360. package/uv.lock +973 -9
  361. package/src/devcouncil/artifacts/migrations.py +0 -20
  362. package/src/devcouncil/artifacts/schemas.py +0 -23
  363. package/src/devcouncil/artifacts/serializer.py +0 -21
  364. package/src/devcouncil/integrations/gitnexus.py +0 -70
  365. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -0,0 +1,862 @@
1
+ """Build / load / incrementally refresh the code knowledge graph (schema v2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import tempfile
11
+ import weakref
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Set
14
+
15
+ from devcouncil.codeintel.languages.registry import LANGUAGE_SPECS
16
+ from devcouncil.indexing.graph.cache import (
17
+ PARSE_CACHE_VERSION,
18
+ extract_cached,
19
+ load_parse_cache,
20
+ merge_parse_cache,
21
+ )
22
+ from devcouncil.indexing.graph.extract_python import FileExtraction
23
+ from devcouncil.indexing.graph.liveness import (
24
+ build_liveness_shard,
25
+ file_liveness,
26
+ legacy_dead_strings,
27
+ symbol_reachability_dead,
28
+ _token_scan_dead,
29
+ )
30
+ from devcouncil.indexing.graph.resolve import (
31
+ build_file_and_symbol_nodes,
32
+ contains_and_defines_edges,
33
+ decorator_edges,
34
+ inherit_edges,
35
+ named_import_edges,
36
+ resolve_calls,
37
+ resolve_import_edges,
38
+ import_graph_edges,
39
+ )
40
+ from devcouncil.indexing.graph.schema import NodeKind
41
+ from devcouncil.indexing.graph.schema import CodeGraph, SCHEMA_VERSION
42
+ from devcouncil.utils.json_persist import read_json
43
+
44
+ logger = logging.getLogger(__name__)
45
+ _PENDING_ANALYSIS_SHARDS: dict[int, dict[str, dict[str, object]]] = {}
46
+
47
+ GRAPH_REL = Path(".devcouncil") / "graph" / "code_graph.json"
48
+
49
+ _CODE_SUFFIXES = {
50
+ extension.lower()
51
+ for spec in LANGUAGE_SPECS
52
+ for extension in spec.extensions
53
+ }
54
+
55
+
56
+ class CompatibilityGraphTooLarge(ValueError):
57
+ """Compatibility JSON exceeded the configured import/export boundary."""
58
+
59
+
60
+ def graph_path(root: Path) -> Path:
61
+ return root / GRAPH_REL
62
+
63
+
64
+ def content_fingerprint(root: Path, files: List[str]) -> str:
65
+ """sha1 over sorted ``(path, size, mtime_ns)`` so content edits mark staleness."""
66
+ lines: List[str] = []
67
+ for rel in sorted(files):
68
+ try:
69
+ st = (root / rel).stat()
70
+ lines.append(f"{rel}\0{st.st_size}\0{st.st_mtime_ns}")
71
+ except OSError:
72
+ lines.append(f"{rel}\0-1\0-1")
73
+ return hashlib.sha1("\n".join(lines).encode("utf-8")).hexdigest()
74
+
75
+
76
+ def _git_head(root: Path) -> str:
77
+ from devcouncil.utils.proc import git_output
78
+
79
+ return git_output(["rev-parse", "HEAD"], cwd=root, default="").strip()
80
+
81
+
82
+ def _files_fingerprint(files: List[str]) -> str:
83
+ return hashlib.sha1("\n".join(sorted(files)).encode("utf-8")).hexdigest()
84
+
85
+
86
+ def _code_files(files: Iterable[str]) -> List[str]:
87
+ from devcouncil.indexing.wiring import is_vendored_path
88
+
89
+ return sorted(
90
+ f
91
+ for f in files
92
+ if Path(f).suffix.lower() in _CODE_SUFFIXES and not is_vendored_path(f)
93
+ )
94
+
95
+
96
+ def _area_fn_for(root: Path, mapper: Optional[Any] = None):
97
+ """Prefer RepoMapper area bucketing; reuse one shared mapper instance."""
98
+
99
+ def _area(path: str) -> str:
100
+ try:
101
+ if mapper is not None:
102
+ return str(mapper._area_for_file(path))
103
+ from devcouncil.indexing.repo_mapper import RepoMapper
104
+
105
+ return RepoMapper(root)._area_for_file(path)
106
+ except Exception:
107
+ parts = path.replace("\\", "/").split("/")
108
+ return parts[0] if parts else "root"
109
+
110
+ return _area
111
+
112
+
113
+ def _cache_entry_matches_disk(root: Path, rel: str, entry: dict) -> bool:
114
+ """True when cached sha256 (and size/mtime when present) still match disk."""
115
+ path = root / rel
116
+ try:
117
+ st = path.stat()
118
+ except OSError:
119
+ return False
120
+ if "size" in entry and entry["size"] is not None:
121
+ try:
122
+ if int(entry["size"]) != st.st_size:
123
+ return False
124
+ except (TypeError, ValueError):
125
+ return False
126
+ if "mtime_ns" in entry and entry["mtime_ns"] is not None:
127
+ try:
128
+ if int(entry["mtime_ns"]) != st.st_mtime_ns:
129
+ return False
130
+ except (TypeError, ValueError):
131
+ return False
132
+ stored = entry.get("sha256")
133
+ if not isinstance(stored, str) or not stored:
134
+ return False
135
+ try:
136
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
137
+ except OSError:
138
+ return False
139
+ return stored == digest
140
+
141
+
142
+ def extract_all(
143
+ root: Path,
144
+ files: List[str],
145
+ *,
146
+ changed_paths: Optional[Set[str]] = None,
147
+ progress: Callable[[str, int, int], None] | None = None,
148
+ ) -> Dict[str, FileExtraction]:
149
+ """Extract (cache v5) all code files; only force-reparse ``changed_paths`` when set.
150
+
151
+ Incremental cache hits still verify sha256 (and size/mtime if stored) so a
152
+ concurrent edit to a non-listed path cannot stamp a fresh fingerprint over
153
+ stale symbols. Cache-hit entries stay in ``updates`` so merge does not evict them.
154
+ """
155
+ code = _code_files(files)
156
+ cache = load_parse_cache(root)
157
+ extractions: Dict[str, FileExtraction] = {}
158
+ updates: Dict[str, dict] = {}
159
+ force_set = changed_paths if changed_paths is not None else None
160
+
161
+ for index, rel in enumerate(code, start=1):
162
+ if progress is not None:
163
+ progress("extract", index - 1, len(code))
164
+ force = force_set is not None and rel in force_set
165
+ # When incremental: prefer warm cache for non-listed paths, but never
166
+ # trust an entry whose digests no longer match disk.
167
+ if force_set is not None and rel not in force_set:
168
+ entry = cache.get(rel)
169
+ if (
170
+ isinstance(entry, dict)
171
+ and isinstance(entry.get("symbols"), list)
172
+ and isinstance(entry.get("import_details"), list)
173
+ and _cache_entry_matches_disk(root, rel, entry)
174
+ ):
175
+ from devcouncil.indexing.graph.cache import extraction_from_cache_entry
176
+
177
+ extractions[rel] = extraction_from_cache_entry(rel, entry)
178
+ # Keep cache-hit entries in ``updates`` — ``merge_parse_cache``
179
+ # prunes managed paths absent from updates, so omitting them
180
+ # would evict every unchanged file from the warm cache.
181
+ updates[rel] = entry
182
+ if progress is not None:
183
+ progress("extract", index, len(code))
184
+ continue
185
+ ext, entry = extract_cached(root, rel, cache=cache, force=bool(force))
186
+ extractions[rel] = ext
187
+ updates[rel] = entry
188
+
189
+ if progress is not None:
190
+ progress("extract", index, len(code))
191
+
192
+ managed = {k for k in cache if Path(k).suffix.lower() in _CODE_SUFFIXES} | set(code)
193
+ if updates:
194
+ merge_parse_cache(root, updates, managed)
195
+ return extractions
196
+
197
+
198
+ def extract_paths(
199
+ root: Path,
200
+ paths: Iterable[str],
201
+ ) -> Dict[str, FileExtraction]:
202
+ """Extract only existing changed paths and evict deleted cache entries."""
203
+ selected = {
204
+ path.replace("\\", "/")
205
+ for path in paths
206
+ if Path(path).suffix.lower() in _CODE_SUFFIXES
207
+ }
208
+ cache = load_parse_cache(root)
209
+ updates: Dict[str, dict] = {}
210
+ extractions: Dict[str, FileExtraction] = {}
211
+ for rel in sorted(selected):
212
+ if not (root / rel).is_file():
213
+ continue
214
+ extraction, entry = extract_cached(root, rel, cache=cache, force=True)
215
+ extractions[rel] = extraction
216
+ updates[rel] = entry
217
+ merge_parse_cache(root, updates, selected)
218
+ return extractions
219
+
220
+
221
+ def assemble_graph(
222
+ root: Path,
223
+ files: List[str],
224
+ extractions: Dict[str, FileExtraction],
225
+ *,
226
+ liveness: bool = True,
227
+ lsp_refs: bool = False,
228
+ mapper: Optional[Any] = None,
229
+ progress: Callable[[str, int, int], None] | None = None,
230
+ ) -> CodeGraph:
231
+ """Resolve + liveness over extractions; always full resolve."""
232
+ from devcouncil.indexing.repo_mapper import RepoMapper
233
+
234
+ if mapper is None:
235
+ mapper = RepoMapper(root)
236
+ if progress is not None:
237
+ progress("assemble_nodes", 0, 1)
238
+ area_fn = _area_fn_for(root, mapper=mapper)
239
+ nodes, symbol_index = build_file_and_symbol_nodes(extractions, area_fn=area_fn)
240
+ class_ids = {n.id for n in nodes if n.kind == NodeKind.CLASS}
241
+ edges = []
242
+ edges.extend(contains_and_defines_edges(extractions))
243
+ edges.extend(inherit_edges(extractions, symbol_index))
244
+ edges.extend(decorator_edges(extractions, symbol_index))
245
+
246
+ file_edges = resolve_import_edges(extractions, files, root=root, mapper=mapper)
247
+ edges.extend(import_graph_edges(file_edges))
248
+ edges.extend(named_import_edges(extractions, symbol_index, file_edges))
249
+ edges.extend(
250
+ resolve_calls(extractions, symbol_index, file_edges, class_ids=class_ids)
251
+ )
252
+ if progress is not None:
253
+ progress("assemble_nodes", 1, 1)
254
+
255
+ semantic_meta: Dict[str, Any] = {}
256
+ try:
257
+ from devcouncil.codeintel.resolution import enrich_semantic_edges
258
+
259
+ enrich_budget = 120.0
260
+ try:
261
+ from devcouncil.app.config import load_config
262
+
263
+ enrich_budget = float(
264
+ load_config(root).indexing.semantic_enrich_timeout_seconds
265
+ )
266
+ except Exception:
267
+ logger.debug("semantic enrich budget config load failed", exc_info=True)
268
+
269
+ semantic_graph = CodeGraph(nodes=nodes, edges=edges)
270
+ enrich_semantic_edges(
271
+ semantic_graph,
272
+ root=root,
273
+ extractions=extractions,
274
+ progress=progress,
275
+ budget_seconds=enrich_budget,
276
+ )
277
+ nodes = semantic_graph.nodes
278
+ edges = semantic_graph.edges
279
+ semantic_meta = semantic_graph.meta
280
+ except Exception:
281
+ logger.debug("semantic graph enrichment failed", exc_info=True)
282
+
283
+ entry_roots: List[str] = []
284
+ unwired: List[str] = []
285
+ unreachable: List[str] = []
286
+ unreachable_unreliable = False
287
+ dead_code = []
288
+ if liveness:
289
+ if progress is not None:
290
+ progress("liveness", 0, 1)
291
+ from devcouncil.indexing.graph.liveness import project_call_edges_to_files
292
+
293
+ liveness_edges = sorted(set(file_edges) | project_call_edges_to_files(edges))
294
+ entry_roots, unwired, unreachable, unreachable_unreliable = file_liveness(
295
+ root, files, liveness_edges, cap=0
296
+ )
297
+ # Empty prod roots → fail-soft empty unreachable (already); keep symbol
298
+ # reachability from treating every file as unreachable.
299
+ reach_unreachable = [] if unreachable_unreliable else unreachable
300
+ token_dead, _idx, token_keys = _token_scan_dead(
301
+ root, files, cap=0, lsp_refs=lsp_refs, mapper=mapper
302
+ )
303
+ dead_code = symbol_reachability_dead(
304
+ root,
305
+ files,
306
+ nodes,
307
+ edges,
308
+ extractions,
309
+ entry_roots,
310
+ token_dead_keys=token_keys,
311
+ file_edges=file_edges,
312
+ unreachable=reach_unreachable,
313
+ )
314
+ # Uncapped in code_graph meta; repo_map.json applies _LIVENESS_CAP on write.
315
+ legacy = legacy_dead_strings(dead_code, token_dead, cap=None)
316
+ if progress is not None:
317
+ progress("liveness", 1, 1)
318
+ else:
319
+ legacy = []
320
+ token_dead = []
321
+
322
+ graph = CodeGraph(
323
+ schema_version=SCHEMA_VERSION,
324
+ nodes=nodes,
325
+ edges=edges,
326
+ dead_code=dead_code,
327
+ entry_roots=entry_roots,
328
+ unwired_candidates=unwired,
329
+ unreachable_files=unreachable,
330
+ generated_head=_git_head(root),
331
+ indexed_hash=_files_fingerprint(files),
332
+ content_fingerprint=content_fingerprint(root, files),
333
+ meta={
334
+ "parse_cache_version": PARSE_CACHE_VERSION,
335
+ "legacy_dead_symbol_candidates": legacy,
336
+ "file_edge_count": len(file_edges),
337
+ "token_dead_count": len(token_dead) if liveness else 0,
338
+ "liveness_unreachable_unreliable": unreachable_unreliable,
339
+ **semantic_meta,
340
+ },
341
+ )
342
+ try:
343
+ from devcouncil.indexing.graph.intel import enrich_graph_intel
344
+
345
+ enrich_graph_intel(graph, root=root)
346
+ except Exception:
347
+ logger.debug("graph intel enrichment failed", exc_info=True)
348
+ return graph
349
+
350
+
351
+ def build_code_graph(
352
+ root: Path,
353
+ files: Optional[List[str]] = None,
354
+ *,
355
+ changed_paths: Optional[Iterable[str]] = None,
356
+ liveness: bool = True,
357
+ lsp_refs: bool = False,
358
+ mapper: Optional[Any] = None,
359
+ progress: Callable[[str, int, int], None] | None = None,
360
+ ) -> CodeGraph:
361
+ """Full (or incremental-extract) graph build."""
362
+ from devcouncil.indexing.repo_mapper import RepoMapper
363
+
364
+ root = root.expanduser().resolve()
365
+ if mapper is None:
366
+ mapper = RepoMapper(root)
367
+ if files is None:
368
+ files = mapper.get_git_files()
369
+ changed = {p.replace("\\", "/") for p in changed_paths} if changed_paths else None
370
+ extractions = extract_all(root, files, changed_paths=changed, progress=progress)
371
+ graph = assemble_graph(
372
+ root,
373
+ files,
374
+ extractions,
375
+ liveness=liveness,
376
+ lsp_refs=lsp_refs,
377
+ mapper=mapper,
378
+ progress=progress,
379
+ )
380
+ _PENDING_ANALYSIS_SHARDS[id(graph)] = {
381
+ path: build_liveness_shard(root, extraction)
382
+ for path, extraction in extractions.items()
383
+ }
384
+ weakref.finalize(graph, _PENDING_ANALYSIS_SHARDS.pop, id(graph), None)
385
+ return graph
386
+
387
+
388
+ def _slim_graph_export(graph: CodeGraph) -> CodeGraph:
389
+ """Copy for JSON export: drop bulky meta already recoverable elsewhere.
390
+
391
+ ``node_communities`` duplicates per-node ``community``; legacy dead strings
392
+ remain uncapped on the in-memory/SQLite graph for repo_map consumers.
393
+ Volatile PageRank floats are stripped from ``god_nodes`` (degree/fan metrics
394
+ remain); incremental bookkeeping keys stay but no longer re-inflate via
395
+ pretty-indent or duplicated community maps.
396
+ """
397
+ meta = dict(graph.meta or {})
398
+ meta.pop("node_communities", None)
399
+ meta.pop("legacy_dead_symbol_candidates", None)
400
+ gods = meta.get("god_nodes")
401
+ if isinstance(gods, list):
402
+ cleaned: list[dict[str, object]] = []
403
+ for row in gods:
404
+ if not isinstance(row, dict):
405
+ continue
406
+ item = dict(row)
407
+ item.pop("pagerank", None)
408
+ cleaned.append(item)
409
+ meta["god_nodes"] = cleaned
410
+ meta["compatibility_export_tier"] = "slim"
411
+ return graph.model_copy(update={"meta": meta})
412
+
413
+
414
+ def _compact_graph_export(graph: CodeGraph) -> CodeGraph:
415
+ """Aggressive slim: strip node/edge extras and cap noisy liveness lists."""
416
+ slim = _slim_graph_export(graph)
417
+ nodes = [
418
+ n.model_copy(update={"extras": {}})
419
+ for n in slim.nodes
420
+ ]
421
+ edges = [
422
+ e.model_copy(update={"extras": {}, "reason": ""})
423
+ for e in slim.edges
424
+ ]
425
+ meta = dict(slim.meta or {})
426
+ meta["compatibility_export_tier"] = "compact"
427
+ meta["unreachable_omitted"] = len(slim.unreachable_files or [])
428
+ meta["unwired_omitted"] = max(0, len(slim.unwired_candidates or []) - 200)
429
+ return slim.model_copy(
430
+ update={
431
+ "nodes": nodes,
432
+ "edges": edges,
433
+ "unreachable_files": [],
434
+ "unwired_candidates": list(slim.unwired_candidates or [])[:200],
435
+ "dead_code": list(slim.dead_code or [])[:500],
436
+ "meta": meta,
437
+ }
438
+ )
439
+
440
+
441
+ def _stub_graph_export(graph: CodeGraph) -> CodeGraph:
442
+ """Pointer-only compatibility JSON when even compact export exceeds the limit."""
443
+ return CodeGraph(
444
+ schema_version=graph.schema_version,
445
+ nodes=[],
446
+ edges=[],
447
+ dead_code=[],
448
+ entry_roots=list(graph.entry_roots or [])[:200],
449
+ unwired_candidates=list(graph.unwired_candidates or [])[:100],
450
+ unreachable_files=[],
451
+ generated_head=graph.generated_head,
452
+ indexed_hash=graph.indexed_hash,
453
+ content_fingerprint=graph.content_fingerprint,
454
+ meta={
455
+ "compatibility_export_tier": "stub",
456
+ "sqlite_canonical": True,
457
+ "node_count": len(graph.nodes),
458
+ "edge_count": len(graph.edges),
459
+ "dead_code_count": len(graph.dead_code or []),
460
+ "unwired_count": len(graph.unwired_candidates or []),
461
+ "unreachable_count": len(graph.unreachable_files or []),
462
+ "liveness_unreachable_unreliable": bool(
463
+ (graph.meta or {}).get("liveness_unreachable_unreliable")
464
+ ),
465
+ "compatibility_export_reason": (
466
+ "exceeded indexing.graph_json_max_bytes; prefer SQLite-backed "
467
+ "`dev graph` commands"
468
+ ),
469
+ },
470
+ )
471
+
472
+
473
+ def _graph_json_indent(root: Path) -> int | None:
474
+ """Compact JSON by default; honor ``indexing.compact_graph_json``."""
475
+ try:
476
+ from devcouncil.app.config import load_config
477
+
478
+ if bool(load_config(root).indexing.compact_graph_json):
479
+ return None
480
+ return 2
481
+ except Exception:
482
+ return None
483
+
484
+
485
+ def _graph_json_max_bytes(root: Path) -> int:
486
+ try:
487
+ from devcouncil.app.config import load_config
488
+
489
+ return int(load_config(root).indexing.graph_json_max_bytes)
490
+ except Exception:
491
+ return 128 * 1024 * 1024
492
+
493
+
494
+ def _write_graph_json_bounded(
495
+ path: Path,
496
+ graph: CodeGraph,
497
+ *,
498
+ indent: int | None,
499
+ max_bytes: int,
500
+ ) -> None:
501
+ """Stream JSON to a sibling temporary file and atomically publish it."""
502
+ path.parent.mkdir(parents=True, exist_ok=True)
503
+ encoder = json.JSONEncoder(
504
+ indent=indent,
505
+ ensure_ascii=False,
506
+ separators=(",", ":") if indent is None else None,
507
+ )
508
+ payload = graph.model_dump(mode="json")
509
+ fd, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
510
+ temp = Path(raw_temp)
511
+ written = 0
512
+ try:
513
+ os.chmod(temp, (path.stat().st_mode & 0o777) if path.exists() else 0o644)
514
+ with os.fdopen(fd, "wb") as handle:
515
+ for chunk in encoder.iterencode(payload):
516
+ encoded = chunk.encode("utf-8")
517
+ written += len(encoded)
518
+ if written + 1 > max_bytes:
519
+ raise CompatibilityGraphTooLarge(
520
+ f"compatibility graph export exceeds {max_bytes} bytes"
521
+ )
522
+ handle.write(encoded)
523
+ handle.write(b"\n")
524
+ handle.flush()
525
+ os.fsync(handle.fileno())
526
+ os.replace(temp, path)
527
+ finally:
528
+ temp.unlink(missing_ok=True)
529
+
530
+
531
+ def _write_compatibility_export_tiers(root: Path, path: Path, graph: CodeGraph) -> str:
532
+ """Try slim → compact → stub until the size cap fits. Returns the tier used.
533
+
534
+ Raises ``CompatibilityGraphTooLarge`` only when even the stub cannot be written
535
+ (misconfigured tiny limit). When the stub tier is used, still raises after a
536
+ successful write so callers mark ``compatibility_export=degraded`` while
537
+ leaving a usable pointer JSON on disk.
538
+ """
539
+ from devcouncil.codeintel import get_codeintel_service
540
+
541
+ indent = _graph_json_indent(root)
542
+ max_bytes = _graph_json_max_bytes(root)
543
+ # The stub is a bounded pointer JSON (~1 KB floor plus capped entry lists);
544
+ # always leave it on disk even under a tiny configured cap, backstopped at
545
+ # 1 MiB against pathological entry-root lists.
546
+ stub_cap = max(max_bytes, 1024 * 1024)
547
+ tiers: list[tuple[str, CodeGraph, int]] = [
548
+ ("slim", _slim_graph_export(graph), max_bytes),
549
+ ("compact", _compact_graph_export(graph), max_bytes),
550
+ ("stub", _stub_graph_export(graph), stub_cap),
551
+ ]
552
+ last_err: CompatibilityGraphTooLarge | None = None
553
+ for tier_name, export_graph, tier_cap in tiers:
554
+ try:
555
+ _write_graph_json_bounded(
556
+ path, export_graph, indent=indent, max_bytes=tier_cap
557
+ )
558
+ except CompatibilityGraphTooLarge as exc:
559
+ last_err = exc
560
+ logger.warning(
561
+ "compatibility export tier %s exceeded %s bytes; trying next",
562
+ tier_name,
563
+ max_bytes,
564
+ )
565
+ continue
566
+ get_codeintel_service(root).store.record_compatibility_export(path, export_graph)
567
+ if tier_name == "stub":
568
+ raise CompatibilityGraphTooLarge(
569
+ f"compatibility graph export exceeded {max_bytes} bytes; "
570
+ "wrote stub JSON (SQLite remains canonical)"
571
+ )
572
+ return tier_name
573
+ raise last_err or CompatibilityGraphTooLarge(
574
+ f"compatibility graph export exceeds {max_bytes} bytes"
575
+ )
576
+
577
+
578
+ def write_code_graph(
579
+ root: Path,
580
+ graph: CodeGraph,
581
+ *,
582
+ changed_paths: Set[str] | None = None,
583
+ analysis_shards: dict[str, dict[str, object]] | None = None,
584
+ _lease_held: bool = False,
585
+ ) -> Path:
586
+ if not _lease_held:
587
+ from devcouncil.codeintel.build_control import graph_build_session
588
+
589
+ with graph_build_session(root):
590
+ return write_code_graph(
591
+ root,
592
+ graph,
593
+ changed_paths=changed_paths,
594
+ analysis_shards=analysis_shards,
595
+ _lease_held=True,
596
+ )
597
+ # SQLite is the canonical store. Keep the deterministic JSON artifact as a
598
+ # compatibility/export boundary for existing consumers and older clients.
599
+ from devcouncil.codeintel import get_codeintel_service
600
+
601
+ root = root.expanduser().resolve()
602
+ shards = analysis_shards or _PENDING_ANALYSIS_SHARDS.pop(id(graph), None)
603
+ get_codeintel_service(root).persist(
604
+ graph,
605
+ changed_paths=changed_paths,
606
+ analysis_shards=shards,
607
+ )
608
+ path = graph_path(root)
609
+ path.parent.mkdir(parents=True, exist_ok=True)
610
+ _write_compatibility_export_tiers(root, path, graph)
611
+ return path
612
+
613
+
614
+ def export_code_graph_json(root: Path) -> Optional[Path]:
615
+ """Rewrite the compatibility ``code_graph.json`` from the canonical store.
616
+
617
+ Self-heal for a deleted or failed JSON export: loads from SQLite only (no
618
+ re-persist) and rewrites the artifact + export handshake. Returns the path,
619
+ or None when the store has no graph or the write fails.
620
+ """
621
+ from devcouncil.codeintel import get_codeintel_service
622
+
623
+ root = root.expanduser().resolve()
624
+ try:
625
+ service = get_codeintel_service(root)
626
+ graph = service.load()
627
+ if graph is None:
628
+ return None
629
+ path = graph_path(root)
630
+ path.parent.mkdir(parents=True, exist_ok=True)
631
+ try:
632
+ _write_compatibility_export_tiers(root, path, graph)
633
+ except CompatibilityGraphTooLarge:
634
+ # Stub may still be on disk — treat as healed when the file exists.
635
+ if not path.is_file():
636
+ raise
637
+ return path
638
+ except Exception:
639
+ logger.warning("code graph JSON re-export failed", exc_info=True)
640
+ return None
641
+
642
+
643
+ def load_code_graph(root: Path) -> Optional[CodeGraph]:
644
+ from devcouncil.codeintel import get_codeintel_service
645
+
646
+ root = root.expanduser().resolve()
647
+ path = graph_path(root)
648
+ service = get_codeintel_service(root)
649
+ # SQLite is canonical. Import the JSON compatibility artifact only when the
650
+ # store is empty/missing — never let an external JSON rewrite clobber a
651
+ # committed generation (accidental or malicious mtime/digest churn).
652
+ try:
653
+ if not service.store.exists() and path.is_file():
654
+ if path.stat().st_size > _graph_json_max_bytes(root):
655
+ raise CompatibilityGraphTooLarge(
656
+ f"compatibility graph import exceeds {_graph_json_max_bytes(root)} bytes"
657
+ )
658
+ data = read_json(path)
659
+ exported = CodeGraph.model_validate(data)
660
+ # Import is a store write: hold the writer lease like every other
661
+ # persist path. GraphBuildBusy lands in the except below and the
662
+ # read falls through to whatever generation the builder commits.
663
+ from devcouncil.codeintel.build_control import graph_build_session
664
+
665
+ with graph_build_session(root):
666
+ service.persist(exported)
667
+ service.store.record_compatibility_export(path, exported)
668
+ return _annotate_graph_degraded(root, exported)
669
+ recorded_digest, recorded_mtime = service.store.compatibility_export_state()
670
+ if (
671
+ path.is_file()
672
+ and recorded_mtime is not None
673
+ and path.stat().st_mtime_ns != recorded_mtime
674
+ ):
675
+ # Refresh the handshake when the on-disk export matches the store;
676
+ # otherwise leave SQLite authoritative and let ``dev graph doctor``
677
+ # / export self-heal report drift.
678
+ from devcouncil.codeintel.store.sqlite import compatibility_graph_digest
679
+
680
+ if path.stat().st_size <= _graph_json_max_bytes(root):
681
+ data = read_json(path)
682
+ exported = CodeGraph.model_validate(data)
683
+ if compatibility_graph_digest(exported) == recorded_digest:
684
+ service.store.record_compatibility_export(path, exported)
685
+ else:
686
+ logger.info(
687
+ "ignoring external compatibility graph that diverges from "
688
+ "canonical store (sqlite wins); re-export with "
689
+ "`dev graph export` / map refresh if JSON must catch up"
690
+ )
691
+ except Exception:
692
+ logger.debug("Failed to reconcile compatibility graph export", exc_info=True)
693
+ try:
694
+ graph = service.load()
695
+ if graph is not None:
696
+ return _annotate_graph_degraded(root, graph)
697
+ except Exception:
698
+ # A corrupt/newer database must not strand users who still have the
699
+ # versioned JSON export. ``dev graph doctor`` reports the store failure;
700
+ # compatibility reads remain available until it is repaired/rebuilt.
701
+ logger.debug("Failed to load canonical code-intelligence store", exc_info=True)
702
+ if not path.is_file():
703
+ return None
704
+ try:
705
+ if path.stat().st_size > _graph_json_max_bytes(root):
706
+ raise CompatibilityGraphTooLarge(
707
+ f"compatibility graph import exceeds {_graph_json_max_bytes(root)} bytes"
708
+ )
709
+ data = read_json(path)
710
+ return _annotate_graph_degraded(root, CodeGraph.model_validate(data))
711
+ except Exception:
712
+ logger.debug("Failed to load code graph", exc_info=True)
713
+ return None
714
+
715
+
716
+ def _annotate_graph_degraded(root: Path, graph: CodeGraph) -> CodeGraph:
717
+ """Surface repo_map lean/degraded handshake on graph payloads for consumers."""
718
+ map_path = root / ".devcouncil" / "repo_map.json"
719
+ if not map_path.is_file():
720
+ return graph
721
+ try:
722
+ data = read_json(map_path)
723
+ if not isinstance(data, dict) or not data.get("graph_degraded"):
724
+ return graph
725
+ graph.meta["graph_degraded"] = True
726
+ graph.meta["graph_degraded_reason"] = str(data.get("graph_degraded_reason") or "")
727
+ except Exception:
728
+ logger.debug("graph_degraded annotation failed", exc_info=True)
729
+ return graph
730
+
731
+
732
+ def refresh_map_for_paths(
733
+ root: Path,
734
+ paths: List[str],
735
+ *,
736
+ liveness: bool = True,
737
+ fail_on_degraded: bool = True,
738
+ ) -> CodeGraph | None:
739
+ """Refresh graph and repo map once; never retry a failed graph assembly.
740
+
741
+ When ``fail_on_degraded`` is True (default for watch/sync), a lean/degraded
742
+ refresh raises so ``sync_now`` returns False and pending work is not cleared
743
+ as healthy. One-shot CLI callers that intentionally accept lean maps should
744
+ pass ``fail_on_degraded=False`` or call ``refresh_map_artifacts`` directly.
745
+ """
746
+ from devcouncil.indexing.map_artifacts import refresh_map_artifacts
747
+
748
+ root = root.expanduser().resolve()
749
+ result = refresh_map_artifacts(
750
+ root,
751
+ root / ".devcouncil" / "repo_map.json",
752
+ paths=paths,
753
+ liveness=liveness,
754
+ quiet=True,
755
+ )
756
+ if fail_on_degraded and result.degraded:
757
+ raise RuntimeError(result.reason or "graph refresh degraded")
758
+ return result.graph
759
+
760
+
761
+ # --- Opt-in PDG layer (CFG / reaching-def / CDG / taint) ---
762
+
763
+
764
+ def build_pdg_for_paths(
765
+ root: Path,
766
+ graph: CodeGraph,
767
+ *,
768
+ paths: Optional[Iterable[str]] = None,
769
+ ):
770
+ """Analyze Python files and return a PDG layer."""
771
+ from devcouncil.indexing.graph.pdg.cdg import build_cdg
772
+ from devcouncil.indexing.graph.pdg.cfg import build_cfg_for_function
773
+ from devcouncil.indexing.graph.pdg.reaching_def import compute_reaching_defs
774
+ from devcouncil.indexing.graph.pdg.schema import FilePDG, FunctionPDG, PDGLayer, PDG_VERSION
775
+ from devcouncil.indexing.graph.pdg.taint import analyze_taint
776
+
777
+ root = root.expanduser().resolve()
778
+ if paths is None:
779
+ paths = sorted({n.path for n in graph.nodes if n.path.endswith(".py")})
780
+
781
+ def _python_functions(tree: ast.AST) -> List[tuple[str, ast.AST]]:
782
+ out: List[tuple[str, ast.AST]] = []
783
+ if isinstance(tree, ast.Module):
784
+ for node in tree.body:
785
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
786
+ out.append((node.name, node))
787
+ elif isinstance(node, ast.ClassDef):
788
+ for item in node.body:
789
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
790
+ out.append((f"{node.name}.{item.name}", item))
791
+ seen: Set[str] = set()
792
+ unique: List[tuple[str, ast.AST]] = []
793
+ for qual, fn in out:
794
+ if qual in seen:
795
+ continue
796
+ seen.add(qual)
797
+ unique.append((qual, fn))
798
+ return unique
799
+
800
+ layer = PDGLayer(version=PDG_VERSION)
801
+ for raw in paths:
802
+ rel = raw.replace("\\", "/")
803
+ path = root / rel
804
+ if path.suffix.lower() != ".py":
805
+ continue
806
+ try:
807
+ source = path.read_text(encoding="utf-8")
808
+ tree = ast.parse(source, filename=rel)
809
+ except (OSError, SyntaxError, UnicodeDecodeError):
810
+ logger.debug("PDG skip %s", rel, exc_info=True)
811
+ continue
812
+ lines = source.splitlines()
813
+ functions: List[FunctionPDG] = []
814
+ for qualname, fn_node in _python_functions(tree):
815
+ start_line = int(getattr(fn_node, "lineno", 0) or 0)
816
+ end_line = int(getattr(fn_node, "end_lineno", start_line) or start_line)
817
+ cfg = build_cfg_for_function(rel, qualname, fn_node, lines)
818
+ reaching = compute_reaching_defs(cfg, fn_node)
819
+ cdg = build_cdg(cfg, fn_node)
820
+ taint = analyze_taint(rel, qualname, fn_node, reaching)
821
+ functions.append(
822
+ FunctionPDG(
823
+ path=rel,
824
+ qualname=qualname,
825
+ start_line=start_line,
826
+ end_line=end_line,
827
+ blocks=cfg.blocks,
828
+ cfg_edges=cfg.edges,
829
+ reaching_def=reaching,
830
+ cdg=cdg,
831
+ taint=taint,
832
+ )
833
+ )
834
+ if functions:
835
+ file_pdg = FilePDG(path=rel, language="python", functions=functions)
836
+ layer.files[rel] = file_pdg
837
+ for fn in functions:
838
+ layer.taint_findings.extend(fn.taint)
839
+ return layer
840
+
841
+ def merge_pdg_into_graph(graph: CodeGraph, layer) -> Dict[str, dict[str, object]]:
842
+ """Persist PDG summary in graph.meta and return analysis shards."""
843
+ graph.meta["pdg"] = layer.to_meta()
844
+ shards: Dict[str, dict[str, object]] = {}
845
+ for path in layer.files:
846
+ payload = layer.shard_payload(path)
847
+ if payload is not None:
848
+ shards[path] = payload
849
+ return shards
850
+
851
+
852
+ def load_pdg_layer(graph: CodeGraph):
853
+ from devcouncil.indexing.graph.pdg.schema import PDGLayer, PDG_VERSION, TaintFinding
854
+
855
+ raw = graph.meta.get("pdg")
856
+ if not isinstance(raw, dict):
857
+ return None
858
+ layer = PDGLayer(version=int(raw.get("version") or PDG_VERSION))
859
+ for item in raw.get("taint_findings") or []:
860
+ if isinstance(item, dict):
861
+ layer.taint_findings.append(TaintFinding.from_dict(item))
862
+ return layer