devcouncil 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (365) hide show
  1. package/README.md +46 -30
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +49 -30
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +960 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +303 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/tasks.py +12 -3
  255. package/src/devcouncil/live/transcripts.py +69 -2
  256. package/src/devcouncil/llm/cache.py +5 -6
  257. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  258. package/src/devcouncil/llm/provider.py +647 -73
  259. package/src/devcouncil/llm/router.py +271 -46
  260. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  261. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  262. package/src/devcouncil/optimization/skillopt.py +9 -5
  263. package/src/devcouncil/planning/arbiter_service.py +12 -3
  264. package/src/devcouncil/planning/correction_manifest.py +107 -10
  265. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  266. package/src/devcouncil/planning/plan_service.py +5 -2
  267. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  268. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  269. package/src/devcouncil/planning/question_conversion.py +56 -0
  270. package/src/devcouncil/planning/spec_service.py +9 -3
  271. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  272. package/src/devcouncil/repo/gitignore.py +1 -2
  273. package/src/devcouncil/reporting/evidence_export.py +124 -0
  274. package/src/devcouncil/reporting/evidence_html.py +210 -0
  275. package/src/devcouncil/reporting/json_report.py +16 -12
  276. package/src/devcouncil/reporting/markdown_report.py +38 -9
  277. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  278. package/src/devcouncil/reporting/report_builder.py +40 -4
  279. package/src/devcouncil/reporting/task_provenance.py +42 -0
  280. package/src/devcouncil/reporting/verdict.py +75 -0
  281. package/src/devcouncil/skills/library/README.md +1 -0
  282. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  283. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  285. package/src/devcouncil/skills/registry.py +43 -12
  286. package/src/devcouncil/storage/db.py +57 -11
  287. package/src/devcouncil/storage/models.py +6 -0
  288. package/src/devcouncil/storage/native.py +5 -3
  289. package/src/devcouncil/storage/repositories.py +50 -18
  290. package/src/devcouncil/telemetry/context.py +28 -0
  291. package/src/devcouncil/telemetry/cost.py +4 -5
  292. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  293. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  294. package/src/devcouncil/telemetry/stages.py +27 -2
  295. package/src/devcouncil/telemetry/tracker.py +50 -13
  296. package/src/devcouncil/ui/dashboard.py +120 -8
  297. package/src/devcouncil/utils/fsio.py +58 -0
  298. package/src/devcouncil/utils/git_snapshot.py +112 -0
  299. package/src/devcouncil/utils/json_persist.py +53 -0
  300. package/src/devcouncil/utils/proc.py +89 -0
  301. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  302. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  303. package/src/devcouncil/verification/checks/__init__.py +41 -0
  304. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  305. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  306. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  307. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  308. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  309. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  310. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  311. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  312. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  313. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  314. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  315. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  316. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  317. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  318. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  319. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  320. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  321. package/src/devcouncil/verification/checks/wiring.py +216 -0
  322. package/src/devcouncil/verification/claims/__init__.py +23 -0
  323. package/src/devcouncil/verification/claims/checks.py +395 -0
  324. package/src/devcouncil/verification/claims/mapper.py +168 -0
  325. package/src/devcouncil/verification/claims/models.py +39 -0
  326. package/src/devcouncil/verification/claims/transcript.py +92 -0
  327. package/src/devcouncil/verification/claims/verdict.py +88 -0
  328. package/src/devcouncil/verification/command_evidence.py +170 -0
  329. package/src/devcouncil/verification/command_malformation.py +147 -0
  330. package/src/devcouncil/verification/command_runner.py +164 -0
  331. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  332. package/src/devcouncil/verification/diff_coverage.py +151 -0
  333. package/src/devcouncil/verification/difficulty.py +296 -0
  334. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  335. package/src/devcouncil/verification/gap_ids.py +63 -0
  336. package/src/devcouncil/verification/gate_cache.py +194 -0
  337. package/src/devcouncil/verification/gate_selector.py +344 -0
  338. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  339. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  340. package/src/devcouncil/verification/incremental_check.py +241 -0
  341. package/src/devcouncil/verification/next_actions.py +60 -1
  342. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  343. package/src/devcouncil/verification/sandbox.py +38 -11
  344. package/src/devcouncil/verification/stub_detector.py +369 -0
  345. package/src/devcouncil/verification/test_resolver.py +67 -1
  346. package/src/devcouncil/verification/verifier.py +137 -1666
  347. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  348. package/src/devcouncil/verification/verify_setup.py +176 -0
  349. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  350. package/src/semantic_layer/__init__.py +58 -0
  351. package/src/semantic_layer/benchmark.py +75 -0
  352. package/src/semantic_layer/cache.py +290 -0
  353. package/src/semantic_layer/compressor.py +137 -0
  354. package/src/semantic_layer/config.py +75 -0
  355. package/src/semantic_layer/embeddings.py +69 -0
  356. package/src/semantic_layer/llm_backends.py +99 -0
  357. package/src/semantic_layer/pipeline.py +111 -0
  358. package/src/semantic_layer/router.py +128 -0
  359. package/src/semantic_layer/tuner.py +72 -0
  360. package/uv.lock +973 -9
  361. package/src/devcouncil/artifacts/migrations.py +0 -20
  362. package/src/devcouncil/artifacts/schemas.py +0 -23
  363. package/src/devcouncil/artifacts/serializer.py +0 -21
  364. package/src/devcouncil/integrations/gitnexus.py +0 -70
  365. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  from typing import List, Dict
3
- from pydantic import BaseModel
3
+ from pydantic import BaseModel, Field
4
4
  from devcouncil.domain.requirement import Requirement
5
5
  from devcouncil.domain.task import Task
6
6
  from devcouncil.llm.router import ModelRouter
@@ -8,8 +8,13 @@ from devcouncil.llm.router import ModelRouter
8
8
  logger = logging.getLogger(__name__)
9
9
 
10
10
  class ArbiterDecision(BaseModel):
11
- accepted_finding_ids: List[str]
12
- rejected_finding_ids: List[Dict[str, str]] # id, reason
11
+ # Empty finding lists are routinely OMITTED (not sent as "[]") by models on
12
+ # providers without grammar-constrained decoding; an absent empty list is
13
+ # not an arbitration failure, so default instead of crashing the run.
14
+ # final_requirements/final_tasks stay required — a decision without them is
15
+ # a real failure the healing/retry path must surface.
16
+ accepted_finding_ids: List[str] = Field(default_factory=list)
17
+ rejected_finding_ids: List[Dict[str, str]] = Field(default_factory=list) # id, reason
13
18
  final_requirements: List[Requirement]
14
19
  final_tasks: List[Task]
15
20
 
@@ -48,6 +53,10 @@ You are the arbiter engineering manager. Your goal is to produce the final, defi
48
53
  - High-severity unrefuted findings from critics must be incorporated into the final requirements or tasks.
49
54
  - If a planner successfully rebutted a finding, you may skip it.
50
55
  - Produce a single, coherent task graph.
56
+ - Set each task's ``difficulty`` field to ``easy``, ``normal``, or ``hard`` based on scope
57
+ (files touched, acceptance criteria count, cross-cutting concerns). Hard tasks get
58
+ stricter verification — prefer splitting work that would score hard into smaller tasks.
59
+ - Mention ``scaffolding`` in a task description only when intentional placeholders are expected.
51
60
  """
52
61
  messages = [
53
62
  {"role": "user", "content": prompt}
@@ -2,11 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import logging
7
6
  import uuid
8
7
  from datetime import datetime, timezone
9
8
  from pathlib import Path
9
+ from typing import Any, cast
10
10
 
11
11
  from pydantic import BaseModel, Field
12
12
 
@@ -17,6 +17,7 @@ from devcouncil.storage.db import get_db
17
17
  from devcouncil.storage.native import CorrectionManifestRepository
18
18
  from devcouncil.storage.repositories import EvidenceRepository, GapRepository, TaskRepository
19
19
  from devcouncil.utils.redaction import redact_text
20
+ from devcouncil.utils.json_persist import read_json, read_model_json, write_model_json
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
@@ -54,6 +55,17 @@ class CorrectionManifest(BaseModel):
54
55
  # Both are redacted and size-bounded before being written.
55
56
  prior_diff: str = ""
56
57
  failing_output: str = ""
58
+ # One line per prior repair attempt (root cause it targeted + whether the
59
+ # blocking gaps changed afterwards), carried forward from the previous manifest
60
+ # so the agent sees its own trajectory instead of rediscovering it.
61
+ attempt_history: list[str] = Field(default_factory=list)
62
+ # Deterministic strategy guidance derived from the attempt state: "change
63
+ # approach" when the same gaps reproduced, "final attempt — analyze, don't
64
+ # stub" when the budget is nearly spent. Empty on a first attempt.
65
+ approach_guidance: str = ""
66
+ # ``file:line reason`` for every stub/placeholder the verifier detected, so the
67
+ # repair prompt names the exact placeholders to replace.
68
+ stub_findings: list[str] = Field(default_factory=list)
57
69
 
58
70
 
59
71
  # Severity ordering: most severe first.
@@ -89,7 +101,13 @@ def remediable_incomplete_gaps(all_gaps: list[Gap]) -> list[Gap]:
89
101
  _GAP_TYPE_PRIORITY = {
90
102
  "test_failed": 0,
91
103
  "acceptance_criteria_unproven": 1,
104
+ "coarse_acceptance_proof": 1,
92
105
  "diff_not_exercised": 1,
106
+ "stub_detected": 1,
107
+ "unwired_file": 1,
108
+ "dead_symbol": 1,
109
+ "stranded_code": 1,
110
+ "stub_declared": 4,
93
111
  "task_not_implemented": 2,
94
112
  "migration_gap": 2,
95
113
  "orphan_diff": 3,
@@ -97,6 +115,7 @@ _GAP_TYPE_PRIORITY = {
97
115
  "dependency_risk": 3,
98
116
  "architecture_drift": 4,
99
117
  "assumption_violated": 4,
118
+ "suspicious_effort": 4,
100
119
  "security_risk": 5,
101
120
  }
102
121
 
@@ -123,11 +142,11 @@ def _latest_agent_run(project_root: Path, task_id: str) -> dict | None:
123
142
  candidates = sorted(runs_dir.glob("*/agent-run.json"), reverse=True)
124
143
  for path in candidates:
125
144
  try:
126
- payload = json.loads(path.read_text(encoding="utf-8"))
145
+ payload = read_json(path)
127
146
  except Exception:
128
147
  continue
129
148
  if payload.get("task_id") == task_id:
130
- return payload
149
+ return cast(dict[Any, Any], payload)
131
150
  return None
132
151
 
133
152
 
@@ -203,6 +222,51 @@ def _collect_failing_output(project_root: Path, failed_results) -> str:
203
222
  return _truncate_tail(redact_text("\n\n".join(blocks)), _MAX_FAILING_OUTPUT_CHARS)
204
223
 
205
224
 
225
+ REPAIR_RULES = (
226
+ "## Repair rules (non-negotiable)\n"
227
+ "1. Only claim completion after every command in `commands_to_rerun` passes locally; "
228
+ "a claim without fresh passing evidence will be rejected by verification.\n"
229
+ "2. Never delete, skip, or weaken a test (or an assertion) to make verification pass.\n"
230
+ "3. Never satisfy a gap with a stub, placeholder, TODO, or hardcoded special-case.\n"
231
+ "4. If `prior_diff` is present, your previous edit was REJECTED — read `failing_output` "
232
+ "and fix the root cause; do not re-apply the same change.\n"
233
+ "5. If the criterion genuinely cannot be met, say so explicitly and explain what is "
234
+ "missing instead of faking progress.\n"
235
+ )
236
+
237
+
238
+ def _build_attempt_history(prior: "CorrectionManifest | None", *, gaps_identical: bool) -> list[str]:
239
+ """Carry the prior manifest's history forward and append one line for the attempt
240
+ that just failed. Empty on a first repair."""
241
+ if prior is None:
242
+ return []
243
+ history = list(prior.attempt_history)
244
+ outcome = "identical blocking gaps reproduced" if gaps_identical else "blocking gaps changed"
245
+ history.append(
246
+ f"attempt {max(1, prior.prior_failed_attempts)}: targeted '{prior.root_cause[:160]}'; {outcome}"
247
+ )
248
+ # Bounded so a long-running loop cannot swell the manifest.
249
+ return history[-10:]
250
+
251
+
252
+ def _build_approach_guidance(
253
+ prior: "CorrectionManifest | None", prior_attempts: int, retry_budget: int, *, gaps_identical: bool
254
+ ) -> str:
255
+ parts: list[str] = []
256
+ if prior is not None and gaps_identical:
257
+ parts.append(
258
+ "Your previous approach failed the same way (identical blocking gaps). Do NOT "
259
+ "retry the same edit; re-read the failing output and change strategy."
260
+ )
261
+ if retry_budget and prior_attempts >= retry_budget:
262
+ parts.append(
263
+ "This is the FINAL budgeted attempt. If an acceptance criterion cannot be met, "
264
+ "leave a clear written analysis of why (and what is missing) instead of "
265
+ "stubbing code or weakening tests."
266
+ )
267
+ return " ".join(parts)
268
+
269
+
206
270
  def build_correction_manifest(
207
271
  project_root: Path,
208
272
  task: Task,
@@ -211,6 +275,7 @@ def build_correction_manifest(
211
275
  repair_service=None,
212
276
  prior_attempts: int = 0,
213
277
  config=None,
278
+ prior_manifest: "CorrectionManifest | None" = None,
214
279
  ) -> CorrectionManifest:
215
280
  # ``config`` may be threaded in by a caller that already loaded it (e.g. the repair
216
281
  # loop, which would otherwise reload config from disk on every attempt). Fall back
@@ -234,10 +299,16 @@ def build_correction_manifest(
234
299
  # not an arbitrary first gap such as an orphan_diff.
235
300
  ordered_gaps = _ordered_blocking_gaps(blocking_gaps)
236
301
  root_cause = ordered_gaps[0].description if ordered_gaps else "Unknown failure"
302
+ ordered_descriptions = [g.description for g in ordered_gaps]
303
+ gaps_identical = bool(
304
+ prior_manifest is not None
305
+ and prior_manifest.ordered_blocking_gaps == ordered_descriptions
306
+ )
307
+ retry_budget = config.execution.max_repair_attempts
237
308
  manifest = CorrectionManifest(
238
309
  task_id=task.id,
239
310
  root_cause=root_cause,
240
- ordered_blocking_gaps=[g.description for g in ordered_gaps],
311
+ ordered_blocking_gaps=ordered_descriptions,
241
312
  failed_evidence=failed,
242
313
  allowed_repair_files=[pf.path for pf in task.planned_files],
243
314
  forbidden_changes=list(task.forbidden_changes),
@@ -246,7 +317,7 @@ def build_correction_manifest(
246
317
  # hardcoded 0. The agent sees how much of its budget is spent so it knows
247
318
  # when to change approach rather than retry the same fix.
248
319
  prior_failed_attempts=prior_attempts,
249
- retry_budget=config.execution.max_repair_attempts,
320
+ retry_budget=retry_budget,
250
321
  executor_recommendation=config.execution.default_executor,
251
322
  created_at=datetime.now(timezone.utc).isoformat(),
252
323
  # Prior-attempt context so the next executor repairs against what actually
@@ -254,6 +325,15 @@ def build_correction_manifest(
254
325
  # the same wrong approach blind. Both are redacted and size-bounded.
255
326
  prior_diff=_collect_prior_diff(project_root, task.id),
256
327
  failing_output=_collect_failing_output(project_root, failed_results),
328
+ attempt_history=_build_attempt_history(prior_manifest, gaps_identical=gaps_identical),
329
+ approach_guidance=_build_approach_guidance(
330
+ prior_manifest, prior_attempts, retry_budget, gaps_identical=gaps_identical
331
+ ),
332
+ stub_findings=[
333
+ f"{g.file}:{g.line} {g.description}" if g.file else g.description
334
+ for g in blocking_gaps
335
+ if g.gap_type == "stub_detected"
336
+ ],
257
337
  )
258
338
 
259
339
  if repair_service is not None:
@@ -271,8 +351,9 @@ def build_correction_manifest(
271
351
  manifest.allowed_repair_files, [pf.path for pf in suggested.planned_files]
272
352
  )
273
353
  manifest.commands_to_rerun = _union(manifest.commands_to_rerun, suggested.expected_tests)
274
- except Exception:
275
- pass
354
+ except Exception as e:
355
+ # The manifest still ships, but without the repair plan's focused scope.
356
+ logger.warning("Repair plan generation failed, keeping heuristic manifest scope: %s", e)
276
357
  return manifest
277
358
 
278
359
 
@@ -311,14 +392,18 @@ def write_correction_manifest(
311
392
  "Writing correction manifest for %s: %d gap(s), prior_attempts=%d",
312
393
  task_id, len(gaps), prior_attempts,
313
394
  )
395
+ # The previous manifest (if any) feeds attempt_history and the identical-gaps
396
+ # comparison behind approach_guidance. Loaded BEFORE the new one is written.
397
+ prior_manifest = load_latest_correction_manifest(project_root, task_id)
314
398
  manifest = build_correction_manifest(
315
- project_root, task, gaps, repair_service=repair_service, prior_attempts=prior_attempts, config=config
399
+ project_root, task, gaps, repair_service=repair_service, prior_attempts=prior_attempts,
400
+ config=config, prior_manifest=prior_manifest,
316
401
  )
317
402
  run_id = str(uuid.uuid4())
318
403
  run_dir = project_root / ".devcouncil" / "runs" / run_id
319
404
  run_dir.mkdir(parents=True, exist_ok=True)
320
405
  path = run_dir / "correction-manifest.json"
321
- path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
406
+ write_model_json(path, manifest)
322
407
 
323
408
  with db.get_session() as session:
324
409
  CorrectionManifestRepository(session).save(
@@ -343,4 +428,16 @@ def load_latest_correction_manifest(project_root: Path, task_id: str) -> Correct
343
428
  path = Path(record.manifest_path)
344
429
  if not path.exists():
345
430
  return None
346
- return CorrectionManifest.model_validate(json.loads(path.read_text(encoding="utf-8")))
431
+ return read_model_json(path, CorrectionManifest)
432
+
433
+
434
+ def repair_prompt_prefix(project_root: Path, task_id: str) -> str:
435
+ """Correction manifest + repair rules for a repair run, or ``\"\"`` on first attempt."""
436
+ correction = load_latest_correction_manifest(project_root, task_id)
437
+ if correction is None:
438
+ return ""
439
+ return (
440
+ f"# DevCouncil Correction Manifest\n\n"
441
+ f"{correction.model_dump_json(indent=2)}\n\n"
442
+ f"{REPAIR_RULES}\n"
443
+ )
@@ -0,0 +1,69 @@
1
+ """Plan-time difficulty tagging so the council sees rigor before execution.
2
+
3
+ The verifier's deterministic estimator runs here on every task the arbiter emits
4
+ without an explicit ``Task.difficulty``. Overscoped tasks that score ``hard`` get
5
+ a human-visible warning so planners can split them before agents start work.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import List, Tuple
12
+
13
+ from devcouncil.domain.requirement import Requirement
14
+ from devcouncil.domain.task import Task
15
+ from devcouncil.verification.difficulty import (
16
+ _HARD_THRESHOLD,
17
+ difficulty_score,
18
+ estimate_difficulty,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def annotate_tasks_with_difficulty(
25
+ tasks: List[Task],
26
+ requirements: List[Requirement] | None = None,
27
+ ) -> List[Task]:
28
+ """Set ``Task.difficulty`` from the estimator when the arbiter left it unset."""
29
+ annotated: List[Task] = []
30
+ for task in tasks:
31
+ if task.difficulty in ("easy", "normal", "hard"):
32
+ annotated.append(task)
33
+ continue
34
+ difficulty = estimate_difficulty(task, requirements)
35
+ annotated.append(task.model_copy(update={"difficulty": difficulty}))
36
+ return annotated
37
+
38
+
39
+ def overscoped_hard_warnings(
40
+ tasks: List[Task],
41
+ requirements: List[Requirement] | None = None,
42
+ ) -> List[str]:
43
+ """Advisory messages for tasks that score hard and may need splitting."""
44
+ warnings: List[str] = []
45
+ for task in tasks:
46
+ score = difficulty_score(task, requirements)
47
+ if score < _HARD_THRESHOLD:
48
+ continue
49
+ writable = [pf for pf in task.planned_files if pf.allowed_change != "read_only"]
50
+ if len(writable) >= 4 or len(task.acceptance_criterion_ids) >= 5:
51
+ warnings.append(
52
+ f"{task.id} ({task.title!r}) scores hard ({score}): "
53
+ f"{len(writable)} writable file(s), "
54
+ f"{len(task.acceptance_criterion_ids)} acceptance criterion(s). "
55
+ "Consider splitting into smaller tasks before execution."
56
+ )
57
+ return warnings
58
+
59
+
60
+ def apply_plan_difficulty(
61
+ tasks: List[Task],
62
+ requirements: List[Requirement] | None = None,
63
+ ) -> Tuple[List[Task], List[str]]:
64
+ """Annotate difficulty and return any overscoped-hard warnings."""
65
+ annotated = annotate_tasks_with_difficulty(tasks, requirements)
66
+ warnings = overscoped_hard_warnings(annotated, requirements)
67
+ for msg in warnings:
68
+ logger.info("Plan difficulty: %s", msg)
69
+ return annotated, warnings
@@ -8,8 +8,11 @@ from devcouncil.llm.router import ModelRouter
8
8
  logger = logging.getLogger(__name__)
9
9
 
10
10
  class PlanOutput(BaseModel):
11
- id: str
12
- rationale: str
11
+ # id/rationale are descriptive metadata some models omit; don't fail an
12
+ # otherwise-valid plan over them. tasks stays required — a plan without
13
+ # tasks is a real failure the healing/retry path must surface.
14
+ id: str = "PLAN"
15
+ rationale: str = ""
13
16
  tasks: List[Task]
14
17
 
15
18
 
@@ -0,0 +1,191 @@
1
+ """Reconcile planner-emitted ``planned_files`` against the real repository.
2
+
3
+ The planner names ``planned_files`` as free-form LLM output. A
4
+ ``modify``/``delete``/``read_only`` entry that points at a path the repo does not
5
+ actually contain is not a harmless typo: it becomes the file whitelist that
6
+ downstream scope enforcement trusts. A plausible-but-wrong path (a typo, or a file
7
+ that was renamed) then silently reverts the legitimate write the agent makes to the
8
+ *real* path, because the real path was never whitelisted.
9
+
10
+ This pass grounds those paths in the repo map's actual file set before the plan is
11
+ persisted. Its guiding invariant is **it only ever relaxes or corrects scope, never
12
+ tightens it** — so it can remove false reverts but never introduce a new one:
13
+
14
+ - ``create`` entries are left as-is (a new file legitimately isn't in the map yet).
15
+ - a path that exists in the map is kept (normalized).
16
+ - a non-existent ``modify``/``delete``/``read_only`` path is *repaired* to a real
17
+ path when exactly one file in the repo shares its basename (the typo/rename case).
18
+ - anything else (no basename match, or an ambiguous multi-match) is kept as-is and
19
+ reported as an advisory warning, never dropped.
20
+
21
+ Import-light (domain + stdlib only) so it stays unit-testable without booting the
22
+ planner's LLM/router stack.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Iterable
28
+
29
+ from devcouncil.domain.task import PlannedFile, Task
30
+
31
+
32
+ def _normalize(path: str) -> str:
33
+ """Strip ``./`` prefixes, normalize separators, and drop trailing slashes."""
34
+ return path.strip().replace("\\", "/").removeprefix("./").rstrip("/")
35
+
36
+
37
+ def _basename(path: str) -> str:
38
+ return path.rsplit("/", 1)[-1]
39
+
40
+
41
+ def repo_files_from_map(repo_map: object) -> list[str]:
42
+ """Extract tracked file paths from a ``RepoMap`` (or its ``dict`` form)."""
43
+ files = getattr(repo_map, "files", None)
44
+ if files is None and isinstance(repo_map, dict):
45
+ files = repo_map.get("files", [])
46
+ result: list[str] = []
47
+ for entry in files or []:
48
+ path = getattr(entry, "path", None)
49
+ if path is None and isinstance(entry, dict):
50
+ path = entry.get("path")
51
+ if path:
52
+ result.append(path)
53
+ return result
54
+
55
+
56
+ def reconcile_planned_files(
57
+ tasks: list[Task],
58
+ repo_files: Iterable[str],
59
+ ) -> tuple[list[Task], list[str]]:
60
+ """Ground each task's ``planned_files`` against the real repo file set.
61
+
62
+ Returns the (possibly rewritten) tasks and a list of human-readable warnings
63
+ describing every repair or unresolved mismatch. When ``repo_files`` is empty
64
+ (no map available), tasks are returned untouched — we can't distinguish a
65
+ hallucinated path from a real one, so we degrade gracefully rather than guess.
66
+ """
67
+ known = {_normalize(f) for f in repo_files if f and f.strip()}
68
+
69
+ if not known:
70
+ return list(tasks), []
71
+
72
+ by_basename: dict[str, set[str]] = {}
73
+ for f in known:
74
+ by_basename.setdefault(_basename(f), set()).add(f)
75
+
76
+ warnings: list[str] = []
77
+ new_tasks: list[Task] = []
78
+
79
+ for task in tasks:
80
+ if not task.planned_files:
81
+ new_tasks.append(task)
82
+ continue
83
+
84
+ changed = False
85
+ kept: list[PlannedFile] = []
86
+ for pf in task.planned_files:
87
+ norm = _normalize(pf.path)
88
+
89
+ # A file this task creates legitimately isn't in the map yet.
90
+ if pf.allowed_change == "create" or norm in known:
91
+ if norm != pf.path:
92
+ kept.append(pf.model_copy(update={"path": norm}))
93
+ changed = True
94
+ else:
95
+ kept.append(pf)
96
+ continue
97
+
98
+ # Non-existent modify/delete/read_only target: repair on a unique
99
+ # basename match (the typo / rename case), otherwise keep + warn.
100
+ candidates = by_basename.get(_basename(norm), set())
101
+ if len(candidates) == 1:
102
+ repaired = next(iter(candidates))
103
+ kept.append(pf.model_copy(update={"path": repaired}))
104
+ changed = True
105
+ warnings.append(
106
+ f"{task.id}: planned file '{pf.path}' not found in repo; "
107
+ f"repaired to '{repaired}' (unique basename match)."
108
+ )
109
+ else:
110
+ kept.append(pf.model_copy(update={"path": norm}) if norm != pf.path else pf)
111
+ if norm != pf.path:
112
+ changed = True
113
+ detail = (
114
+ f"{len(candidates)} basename matches — ambiguous"
115
+ if candidates
116
+ else "no matching file"
117
+ )
118
+ warnings.append(
119
+ f"{task.id}: planned file '{pf.path}' ({pf.allowed_change}) "
120
+ f"not found in repo ({detail}); left as-is for review."
121
+ )
122
+
123
+ new_tasks.append(task.model_copy(update={"planned_files": kept}) if changed else task)
124
+
125
+ return new_tasks, warnings
126
+
127
+
128
+ def expand_scope_with_dependents(
129
+ tasks: list[Task],
130
+ dependents: dict[str, list[str]],
131
+ repo_files: Iterable[str],
132
+ max_per_file: int = 8,
133
+ ) -> tuple[list[Task], list[str]]:
134
+ """Widen each task's ``planned_files`` with the real callers of its writable files.
135
+
136
+ The other half of the scope bug ``reconcile_planned_files`` doesn't cover: the
137
+ planner names ``foo.py`` to modify but not the files that *import* ``foo.py`` and
138
+ must change with it. Those omitted callers are outside the whitelist, so the
139
+ agent's necessary edit to them is reverted. Using the repo map's ``dependents``
140
+ (reverse import edges), add each writable file's callers as ``modify``-scoped
141
+ entries — capped per file and drawn only from files that actually exist, so this
142
+ only ever *relaxes* scope from a grounded signal, never invents paths.
143
+ """
144
+ if not dependents:
145
+ return list(tasks), []
146
+
147
+ known = {_normalize(f) for f in repo_files if f and f.strip()}
148
+ norm_dependents = {
149
+ _normalize(k): [_normalize(v) for v in (vs or [])] for k, vs in dependents.items()
150
+ }
151
+
152
+ warnings: list[str] = []
153
+ new_tasks: list[Task] = []
154
+
155
+ for task in tasks:
156
+ existing = {_normalize(pf.path) for pf in task.planned_files}
157
+ additions: list[PlannedFile] = []
158
+ for pf in task.planned_files:
159
+ if pf.allowed_change not in ("modify", "delete"):
160
+ continue
161
+ norm = _normalize(pf.path)
162
+ added = 0
163
+ for dep in norm_dependents.get(norm, []):
164
+ if added >= max_per_file:
165
+ break
166
+ if dep in existing:
167
+ continue
168
+ if known and dep not in known:
169
+ continue
170
+ existing.add(dep)
171
+ additions.append(
172
+ PlannedFile(
173
+ path=dep,
174
+ reason=f"imports {norm}; may need updating when it changes (repo map dependents)",
175
+ allowed_change="modify",
176
+ )
177
+ )
178
+ added += 1
179
+
180
+ if additions:
181
+ new_tasks.append(
182
+ task.model_copy(update={"planned_files": [*task.planned_files, *additions]})
183
+ )
184
+ warnings.append(
185
+ f"{task.id}: widened scope with {len(additions)} dependent file(s) so a "
186
+ "required caller edit isn't reverted."
187
+ )
188
+ else:
189
+ new_tasks.append(task)
190
+
191
+ return new_tasks, warnings
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from pydantic import BaseModel, Field
5
5
 
6
6
  from devcouncil.llm.router import ModelRouter
7
+ from devcouncil.utils.json_persist import read_model_json, write_model_json
7
8
 
8
9
  logger = logging.getLogger(__name__)
9
10
 
@@ -97,11 +98,10 @@ def load_latest_prompt_enhancement(project_root: Path) -> "PromptEnhancement | N
97
98
  was persisted (so the executor gets the guidance tied to the plan it is running). Falls
98
99
  back to the most recent per-run artifact for plans persisted before that file existed.
99
100
  Best-effort: any read/parse failure returns None so prompt building never breaks."""
100
- import json
101
101
 
102
102
  def _load(path: Path) -> "PromptEnhancement | None":
103
103
  try:
104
- return PromptEnhancement.model_validate(json.loads(path.read_text(encoding="utf-8")))
104
+ return read_model_json(path, PromptEnhancement)
105
105
  except Exception:
106
106
  return None
107
107
 
@@ -125,9 +125,10 @@ def save_active_prompt_enhancement(project_root: Path, enhancement: "PromptEnhan
125
125
  try:
126
126
  path = project_root / ".devcouncil" / _ACTIVE_ENHANCEMENT_FILE
127
127
  path.parent.mkdir(parents=True, exist_ok=True)
128
- path.write_text(enhancement.model_dump_json(indent=2), encoding="utf-8")
129
- except Exception:
130
- pass
128
+ write_model_json(path, enhancement)
129
+ except Exception as e:
130
+ # If this persistence fails, executors silently run without (or with stale) guidance.
131
+ logger.warning("Failed to persist active prompt enhancement: %s", e)
131
132
 
132
133
 
133
134
  class PromptEnhancerService:
@@ -0,0 +1,56 @@
1
+ """Convert blocking spec questions into non-blocking assumptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from devcouncil.domain.assumption import Assumption
6
+ from devcouncil.planning.spec_service import BlockingQuestion
7
+
8
+
9
+ def assumption_id_from_question(question_id: str) -> str:
10
+ """Derive a stable assumption id from a blocking-question id."""
11
+ if question_id.startswith("ASM-"):
12
+ return question_id
13
+ if question_id.startswith("Q-"):
14
+ return f"ASM-from-{question_id}"
15
+ return f"ASM-from-Q-{question_id}"
16
+
17
+
18
+ def blocking_question_to_assumption(question: BlockingQuestion) -> Assumption:
19
+ """Map one blocking question to a low-confidence assumption that does not block approval."""
20
+ statement = question.question
21
+ if question.reason:
22
+ statement = f"{question.question} (Context: {question.reason})"
23
+ return Assumption(
24
+ id=assumption_id_from_question(question.id),
25
+ statement=statement,
26
+ confidence="low",
27
+ impact="medium",
28
+ reversible=True,
29
+ requires_user_confirmation=False,
30
+ status="open",
31
+ )
32
+
33
+
34
+ def convert_blocking_questions_to_assumptions(
35
+ assumptions: list[Assumption],
36
+ blocking_questions: list[BlockingQuestion],
37
+ ) -> tuple[list[Assumption], list[BlockingQuestion]]:
38
+ """Append converted questions as assumptions and return an empty blocking list."""
39
+ if not blocking_questions:
40
+ return assumptions, blocking_questions
41
+
42
+ existing_ids = {assumption.id for assumption in assumptions}
43
+ converted: list[Assumption] = []
44
+ for question in blocking_questions:
45
+ assumption = blocking_question_to_assumption(question)
46
+ candidate_id = assumption.id
47
+ suffix = 1
48
+ while candidate_id in existing_ids:
49
+ candidate_id = f"{assumption.id}-{suffix}"
50
+ suffix += 1
51
+ if candidate_id != assumption.id:
52
+ assumption = assumption.model_copy(update={"id": candidate_id})
53
+ existing_ids.add(assumption.id)
54
+ converted.append(assumption)
55
+
56
+ return [*assumptions, *converted], []
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  from typing import List
3
- from pydantic import BaseModel
3
+ from pydantic import BaseModel, Field
4
4
  from devcouncil.domain.requirement import Requirement
5
5
  from devcouncil.domain.assumption import Assumption
6
6
  from devcouncil.llm.router import ModelRouter
@@ -14,8 +14,14 @@ class BlockingQuestion(BaseModel):
14
14
 
15
15
  class SpecOutput(BaseModel):
16
16
  requirements: List[Requirement]
17
- assumptions: List[Assumption]
18
- blocking_questions: List[BlockingQuestion]
17
+ # Models on providers without grammar-constrained decoding (e.g.
18
+ # gemini-2.5-flash via OpenRouter) routinely OMIT list fields that would be
19
+ # empty instead of emitting "[]". An absent empty list is semantically
20
+ # identical to an empty one, so default rather than crash the planning run
21
+ # with a "Field required" validation error. requirements stays required:
22
+ # a spec without requirements IS a failure worth surfacing.
23
+ assumptions: List[Assumption] = Field(default_factory=list)
24
+ blocking_questions: List[BlockingQuestion] = Field(default_factory=list)
19
25
 
20
26
  class SpecService:
21
27
  def __init__(self, router: ModelRouter):