devcouncil 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/README.md +92 -33
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +56 -40
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +964 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +281 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +87 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +103 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +69 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +53 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +325 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/summary.py +21 -3
  255. package/src/devcouncil/live/tasks.py +12 -3
  256. package/src/devcouncil/live/transcripts.py +69 -2
  257. package/src/devcouncil/llm/cache.py +5 -6
  258. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  259. package/src/devcouncil/llm/provider.py +647 -73
  260. package/src/devcouncil/llm/router.py +271 -46
  261. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  262. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  263. package/src/devcouncil/optimization/skillopt.py +9 -5
  264. package/src/devcouncil/planning/arbiter_service.py +12 -3
  265. package/src/devcouncil/planning/correction_manifest.py +107 -10
  266. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  267. package/src/devcouncil/planning/plan_service.py +5 -2
  268. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  269. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  270. package/src/devcouncil/planning/question_conversion.py +56 -0
  271. package/src/devcouncil/planning/spec_service.py +9 -3
  272. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  273. package/src/devcouncil/repo/gitignore.py +1 -2
  274. package/src/devcouncil/reporting/evidence_export.py +124 -0
  275. package/src/devcouncil/reporting/evidence_html.py +210 -0
  276. package/src/devcouncil/reporting/json_report.py +16 -12
  277. package/src/devcouncil/reporting/markdown_report.py +38 -9
  278. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  279. package/src/devcouncil/reporting/report_builder.py +40 -4
  280. package/src/devcouncil/reporting/task_provenance.py +42 -0
  281. package/src/devcouncil/reporting/verdict.py +75 -0
  282. package/src/devcouncil/skills/library/README.md +1 -0
  283. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  285. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  286. package/src/devcouncil/skills/registry.py +43 -12
  287. package/src/devcouncil/storage/db.py +57 -11
  288. package/src/devcouncil/storage/models.py +6 -0
  289. package/src/devcouncil/storage/native.py +5 -3
  290. package/src/devcouncil/storage/repositories.py +50 -18
  291. package/src/devcouncil/telemetry/context.py +28 -0
  292. package/src/devcouncil/telemetry/cost.py +4 -5
  293. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  294. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  295. package/src/devcouncil/telemetry/stages.py +27 -2
  296. package/src/devcouncil/telemetry/tracker.py +50 -13
  297. package/src/devcouncil/ui/dashboard.py +120 -8
  298. package/src/devcouncil/utils/fsio.py +58 -0
  299. package/src/devcouncil/utils/git_snapshot.py +112 -0
  300. package/src/devcouncil/utils/json_persist.py +53 -0
  301. package/src/devcouncil/utils/proc.py +89 -0
  302. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  303. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  304. package/src/devcouncil/verification/checks/__init__.py +41 -0
  305. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  306. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  307. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  308. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  309. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  310. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  311. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  312. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  313. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  314. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  315. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  316. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  317. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  318. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  319. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  320. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  321. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  322. package/src/devcouncil/verification/checks/wiring.py +216 -0
  323. package/src/devcouncil/verification/claims/__init__.py +23 -0
  324. package/src/devcouncil/verification/claims/checks.py +395 -0
  325. package/src/devcouncil/verification/claims/mapper.py +168 -0
  326. package/src/devcouncil/verification/claims/models.py +39 -0
  327. package/src/devcouncil/verification/claims/transcript.py +92 -0
  328. package/src/devcouncil/verification/claims/verdict.py +88 -0
  329. package/src/devcouncil/verification/command_evidence.py +170 -0
  330. package/src/devcouncil/verification/command_malformation.py +147 -0
  331. package/src/devcouncil/verification/command_runner.py +164 -0
  332. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  333. package/src/devcouncil/verification/diff_coverage.py +151 -0
  334. package/src/devcouncil/verification/difficulty.py +296 -0
  335. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  336. package/src/devcouncil/verification/gap_ids.py +63 -0
  337. package/src/devcouncil/verification/gate_cache.py +194 -0
  338. package/src/devcouncil/verification/gate_selector.py +344 -0
  339. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  340. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  341. package/src/devcouncil/verification/incremental_check.py +241 -0
  342. package/src/devcouncil/verification/next_actions.py +60 -1
  343. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  344. package/src/devcouncil/verification/sandbox.py +38 -11
  345. package/src/devcouncil/verification/stub_detector.py +369 -0
  346. package/src/devcouncil/verification/test_resolver.py +67 -1
  347. package/src/devcouncil/verification/verifier.py +137 -1666
  348. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  349. package/src/devcouncil/verification/verify_setup.py +176 -0
  350. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  351. package/src/semantic_layer/__init__.py +58 -0
  352. package/src/semantic_layer/benchmark.py +75 -0
  353. package/src/semantic_layer/cache.py +290 -0
  354. package/src/semantic_layer/compressor.py +137 -0
  355. package/src/semantic_layer/config.py +75 -0
  356. package/src/semantic_layer/embeddings.py +69 -0
  357. package/src/semantic_layer/llm_backends.py +99 -0
  358. package/src/semantic_layer/pipeline.py +111 -0
  359. package/src/semantic_layer/router.py +128 -0
  360. package/src/semantic_layer/tuner.py +72 -0
  361. package/uv.lock +973 -9
  362. package/src/devcouncil/artifacts/migrations.py +0 -20
  363. package/src/devcouncil/artifacts/schemas.py +0 -23
  364. package/src/devcouncil/artifacts/serializer.py +0 -21
  365. package/src/devcouncil/integrations/gitnexus.py +0 -70
  366. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -0,0 +1,101 @@
1
+ """Diff↔coverage gate extracted from Verifier.verify_task."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, List, Optional
7
+
8
+ from devcouncil.domain.evidence import DiffCoverageEvidence
9
+ from devcouncil.domain.gap import Gap
10
+ from devcouncil.domain.task import Task
11
+ from devcouncil.verification import diff_coverage as dc
12
+
13
+
14
+ @dataclass
15
+ class DiffCoverageGateResult:
16
+ gaps: List[Gap]
17
+ evidence: List[DiffCoverageEvidence]
18
+ coverage_measured: bool
19
+ coverage_skipped_reason: Optional[str]
20
+
21
+
22
+ def run_diff_coverage_gate(
23
+ *,
24
+ task: Task,
25
+ diff_content: str,
26
+ any_passing: bool,
27
+ enforce_cov: bool,
28
+ measure_cov: bool,
29
+ min_ratio: float,
30
+ measure_diff_coverage: Callable[[Task, str], dc.DiffCoverageResult],
31
+ coverage_target_commands: Callable[[Task], List[str]],
32
+ next_gap_id: Callable[[str, str], str],
33
+ ) -> DiffCoverageGateResult:
34
+ """Run the diff↔coverage gate and return gaps + evidence."""
35
+ gaps: List[Gap] = []
36
+ evidence: List[DiffCoverageEvidence] = []
37
+ coverage_measured = False
38
+ coverage_skipped_reason: Optional[str] = None
39
+
40
+ if not measure_cov:
41
+ coverage_skipped_reason = "diff coverage disabled in config"
42
+ elif not diff_content:
43
+ coverage_skipped_reason = "no diff to measure"
44
+ elif not task.acceptance_criterion_ids:
45
+ coverage_skipped_reason = "task has no acceptance criteria"
46
+ elif not any_passing:
47
+ coverage_skipped_reason = "no passing verification command to instrument"
48
+
49
+ if measure_cov and diff_content and task.acceptance_criterion_ids and any_passing:
50
+ cov = measure_diff_coverage(task, diff_content)
51
+ if not cov.measured:
52
+ coverage_skipped_reason = cov.reason or "diff coverage could not be measured"
53
+ if cov.measured:
54
+ coverage_measured = True
55
+ coverage_skipped_reason = None
56
+ evidence.append(DiffCoverageEvidence(
57
+ task_id=task.id,
58
+ tool=cov.tool,
59
+ measured=True,
60
+ changed_lines=cov.changed_executable_lines,
61
+ covered_lines=cov.covered_changed_lines,
62
+ coverage_ratio=cov.ratio,
63
+ uncovered_by_file=cov.uncovered_by_file,
64
+ absent_files=cov.absent_files,
65
+ summary=cov.summary(),
66
+ ))
67
+ failing = cov.covered_changed_lines == 0 if min_ratio <= 0 else cov.ratio < min_ratio
68
+ if failing:
69
+ first_file = next(iter(cov.uncovered_by_file), None)
70
+ first_lines = cov.uncovered_by_file.get(first_file or "", [])
71
+ target_cmds = coverage_target_commands(task)
72
+ gaps.append(Gap(
73
+ id=next_gap_id(task.id, "DIFFCOV"),
74
+ severity="high" if enforce_cov else "medium",
75
+ gap_type="diff_not_exercised",
76
+ task_id=task.id,
77
+ description=(
78
+ f"Verification commands passed but exercised "
79
+ f"{cov.covered_changed_lines}/{cov.changed_executable_lines} changed line(s): "
80
+ f"{cov.summary()}. The acceptance criteria are not proven because the new "
81
+ "logic was never executed by the tests."
82
+ ),
83
+ evidence=[cov.summary()] + [
84
+ f"{path}: lines {lines}" for path, lines in list(cov.uncovered_by_file.items())[:5]
85
+ ],
86
+ recommended_fix=(
87
+ "Add or extend a test that executes the changed lines, then re-verify. "
88
+ "A passing suite that does not run the new code is not acceptance evidence."
89
+ ),
90
+ blocking=enforce_cov,
91
+ file=first_file,
92
+ line=first_lines[0] if first_lines else None,
93
+ suggested_command=target_cmds[0] if target_cmds else None,
94
+ ))
95
+
96
+ return DiffCoverageGateResult(
97
+ gaps=gaps,
98
+ evidence=evidence,
99
+ coverage_measured=coverage_measured,
100
+ coverage_skipped_reason=coverage_skipped_reason,
101
+ )
@@ -0,0 +1,79 @@
1
+ """Heuristic doc→code reference gate for changed documentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Callable, List
9
+
10
+ from devcouncil.domain.gap import Gap
11
+ from devcouncil.domain.task import Task
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _CODE_REF_RE = re.compile(
16
+ r"(?<![`\w])(?:src|tests|docs)/[\w./-]+\.(?:py|md|ts|tsx|js|jsx|json|yaml|yml|toml)"
17
+ )
18
+
19
+
20
+ def _norm(path: str) -> str:
21
+ return path.replace("\\", "/").lstrip("./")
22
+
23
+
24
+ def _broken_refs_in_text(project_root: Path, text: str) -> List[str]:
25
+ broken: List[str] = []
26
+ for match in _CODE_REF_RE.finditer(text):
27
+ ref = match.group(0)
28
+ if not (project_root / ref).is_file():
29
+ broken.append(ref)
30
+ return broken
31
+
32
+
33
+ def detect_doc_code_ref_gaps(
34
+ *,
35
+ task: Task,
36
+ project_root: Path,
37
+ changed_files: List[str],
38
+ diff_content: str,
39
+ next_gap_id: Callable[[str, str], str],
40
+ doc_code_ref_enabled: bool = True,
41
+ doc_code_ref_blocking: bool = False,
42
+ ) -> List[Gap]:
43
+ if not doc_code_ref_enabled:
44
+ return []
45
+ gaps: List[Gap] = []
46
+ try:
47
+ doc_paths = sorted(
48
+ p for p in changed_files
49
+ if _norm(p).endswith(".md") or _norm(p).startswith("docs/")
50
+ )
51
+ for rel in doc_paths:
52
+ path = project_root / rel
53
+ if not path.is_file():
54
+ continue
55
+ try:
56
+ text = path.read_text(encoding="utf-8", errors="replace")
57
+ except OSError:
58
+ continue
59
+ broken = _broken_refs_in_text(project_root, text)
60
+ if not broken:
61
+ continue
62
+ gaps.append(Gap(
63
+ id=next_gap_id(task.id, f"DOCREF-{rel}"),
64
+ severity="high" if doc_code_ref_blocking else "medium",
65
+ gap_type="doc_code_ref",
66
+ task_id=task.id,
67
+ description=(
68
+ f"Changed doc `{rel}` references missing code paths: "
69
+ f"{', '.join(broken[:5])}"
70
+ + (" …" if len(broken) > 5 else "")
71
+ ),
72
+ evidence=[rel, *broken[:8]],
73
+ recommended_fix="Fix or remove broken code references in the changed documentation.",
74
+ blocking=doc_code_ref_blocking,
75
+ file=rel,
76
+ ))
77
+ except Exception:
78
+ logger.debug("detect_doc_code_ref_gaps failed", exc_info=True)
79
+ return gaps
@@ -0,0 +1,336 @@
1
+ """Liveness ratchet: flag existing code newly stranded by this task.
2
+
3
+ Diff-scoped gates catch *new* unwired files / dead symbols. This check catches
4
+ the complementary failure: a task removes the last importer/caller of *existing*
5
+ code, leaving it stranded relative to the checkout-time baseline.
6
+
7
+ Skip when no complete baseline exists (pre-feature tasks). Never raises.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any, Callable, List, Mapping, Optional, Set
15
+
16
+ from devcouncil.domain.gap import Gap
17
+ from devcouncil.domain.task import Task
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _norm(path: str) -> str:
23
+ s = str(path).replace("\\", "/")
24
+ while s.startswith("./"):
25
+ s = s[2:]
26
+ return s
27
+
28
+
29
+ def _as_str_set(values: Any) -> Set[str]:
30
+ if not isinstance(values, list):
31
+ return set()
32
+ return {_norm(str(v)) for v in values if v is not None and str(v).strip()}
33
+
34
+
35
+ def _as_list(value: object) -> list[Any]:
36
+ return list(value) if isinstance(value, list) else []
37
+
38
+
39
+ def _symbol_key(entry: str) -> str:
40
+ """Normalize ``path:line name`` / ``path:line:name`` to a stable identity key.
41
+
42
+ Prefer ``path::name`` so line-number drift does not create false regressions.
43
+ """
44
+ text = str(entry).strip()
45
+ if not text:
46
+ return ""
47
+ # Common map format: "path/to.py:12 symbol_name"
48
+ if " " in text:
49
+ loc, _, name = text.partition(" ")
50
+ path = loc.rsplit(":", 1)[0] if ":" in loc else loc
51
+ return f"{_norm(path)}::{name.strip()}"
52
+ # Fallback: path:line:name or path:name
53
+ parts = text.split(":")
54
+ if len(parts) >= 3 and parts[-2].isdigit():
55
+ return f"{_norm(parts[0])}::{parts[-1].strip()}"
56
+ if len(parts) == 2:
57
+ return f"{_norm(parts[0])}::{parts[1].strip()}"
58
+ return _norm(text)
59
+
60
+
61
+ def _symbol_display(entry: str) -> tuple[str, Optional[int], str]:
62
+ """Return (path, line|None, name) for gap fields."""
63
+ text = str(entry).strip()
64
+ if " " in text:
65
+ loc, _, name = text.partition(" ")
66
+ if ":" in loc:
67
+ path, _, line_s = loc.rpartition(":")
68
+ line = int(line_s) if line_s.isdigit() else None
69
+ return _norm(path), line, name.strip()
70
+ return _norm(loc), None, name.strip()
71
+ parts = text.split(":")
72
+ if len(parts) >= 3 and parts[-2].isdigit():
73
+ return _norm(parts[0]), int(parts[-2]), parts[-1].strip()
74
+ if len(parts) == 2:
75
+ return _norm(parts[0]), None, parts[1].strip()
76
+ return _norm(text), None, ""
77
+
78
+
79
+ def baseline_is_complete(baseline: Mapping[str, Any] | None) -> bool:
80
+ """True when baseline exists and carries the write-completed marker."""
81
+ return bool(baseline and isinstance(baseline, Mapping) and baseline.get("complete") is True)
82
+
83
+
84
+ def _liveness_roots_unreliable(snap: Mapping[str, Any] | None) -> bool:
85
+ """True when unreachable lists must not be trusted (empty roots / flag)."""
86
+ if not snap or not isinstance(snap, Mapping):
87
+ return True
88
+ if snap.get("liveness_unreachable_unreliable") is True:
89
+ return True
90
+ roots = snap.get("entry_roots")
91
+ if not isinstance(roots, list) or not roots:
92
+ return True
93
+ return False
94
+
95
+
96
+ def detect_liveness_regressions(
97
+ baseline: Mapping[str, Any] | None,
98
+ current: Mapping[str, Any] | None,
99
+ task_added_files: Set[str] | None = None,
100
+ *,
101
+ task: Task | None = None,
102
+ next_gap_id: Callable[[str, str], str] | None = None,
103
+ blocking: bool = False,
104
+ diff_added_lines: Mapping[str, Set[int]] | None = None,
105
+ ) -> List[Gap]:
106
+ """Diff baseline vs current liveness; flag newly stranded pre-existing code.
107
+
108
+ Files/symbols added by this task are excluded (covered by ``unwired_file`` /
109
+ ``dead_symbol``). Dead symbols must have existed at baseline (``symbol_index``)
110
+ — a brand-new unused def in an existing file is not ``stranded_code``.
111
+
112
+ Returns an empty list when ``baseline`` is missing or incomplete.
113
+ When either side has empty entry roots / unreliable unreachable, skip
114
+ unreachable diffs (avoids empty-root stranded floods).
115
+ """
116
+ gaps: List[Gap] = []
117
+ if not baseline_is_complete(baseline):
118
+ return gaps
119
+ if not current or not isinstance(current, Mapping):
120
+ return gaps
121
+ assert baseline is not None
122
+ try:
123
+ added = {_norm(p) for p in (task_added_files or set())}
124
+ task_id = task.id if task is not None else "TASK"
125
+ gap_id = next_gap_id or (lambda tid, kind: f"{tid}-{kind}-1")
126
+ added_lines = {
127
+ _norm(p): set(lines)
128
+ for p, lines in (diff_added_lines or {}).items()
129
+ }
130
+
131
+ base_unwired = _as_str_set(baseline.get("unwired_candidates"))
132
+ base_unreachable = _as_str_set(baseline.get("unreachable_files"))
133
+ cur_unwired = _as_str_set(current.get("unwired_candidates"))
134
+ cur_unreachable = _as_str_set(current.get("unreachable_files"))
135
+
136
+ newly_unwired = (cur_unwired - base_unwired) - added
137
+ skip_unreachable = (
138
+ _liveness_roots_unreliable(baseline) or _liveness_roots_unreliable(current)
139
+ )
140
+ newly_unreachable = (
141
+ set()
142
+ if skip_unreachable
143
+ else (cur_unreachable - base_unreachable) - added
144
+ )
145
+ stranded_files = sorted(newly_unwired | newly_unreachable)
146
+
147
+ for path in stranded_files:
148
+ reasons = []
149
+ if path in newly_unwired:
150
+ reasons.append("unwired")
151
+ if path in newly_unreachable:
152
+ reasons.append("unreachable")
153
+ reason = "/".join(reasons) or "stranded"
154
+ gaps.append(Gap(
155
+ id=gap_id(task_id, "STRAND"),
156
+ severity="high" if blocking else "medium",
157
+ gap_type="stranded_code",
158
+ task_id=task_id,
159
+ description=(
160
+ f"Pre-existing file `{path}` became {reason} during this task "
161
+ "(lost its last production importer/reachability path)."
162
+ ),
163
+ evidence=[path, f"regression:{reason}"],
164
+ recommended_fix=(
165
+ "Restore the import/call that kept this module live, or delete "
166
+ "the stranded module if it is intentionally unused."
167
+ ),
168
+ blocking=blocking,
169
+ file=path,
170
+ ))
171
+
172
+ # Symbol-level diff only when baseline was recorded with the same scan
173
+ # algorithm — stale/missing scan_version would otherwise flag every newly
174
+ # detected dead symbol as stranded_code after a detector hardening.
175
+ from devcouncil.indexing.wiring import LIVENESS_SCAN_VERSION
176
+
177
+ if baseline.get("scan_version") != LIVENESS_SCAN_VERSION:
178
+ return gaps
179
+
180
+ base_syms = {
181
+ _symbol_key(s): str(s)
182
+ for s in (baseline.get("dead_symbol_candidates") or [])
183
+ if isinstance(s, str) and _symbol_key(s)
184
+ }
185
+ cur_syms = {
186
+ _symbol_key(s): str(s)
187
+ for s in (current.get("dead_symbol_candidates") or [])
188
+ if isinstance(s, str) and _symbol_key(s)
189
+ }
190
+ # Symbols known at checkout. Without this index (legacy baselines), refuse
191
+ # to flag new dead symbols — avoids double-firing on brand-new defs.
192
+ base_index_raw = baseline.get("symbol_index") or []
193
+ base_index = {
194
+ _norm(str(k)) if "::" in str(k) else _symbol_key(str(k))
195
+ for k in base_index_raw
196
+ if k
197
+ }
198
+ # Normalize path::name keys
199
+ base_index_norm: Set[str] = set()
200
+ for k in base_index:
201
+ if "::" in k:
202
+ path_part, _, name = k.partition("::")
203
+ base_index_norm.add(f"{_norm(path_part)}::{name}")
204
+ else:
205
+ base_index_norm.add(k)
206
+
207
+ for key in sorted(set(cur_syms) - set(base_syms)):
208
+ entry = cur_syms[key]
209
+ path, line, name = _symbol_display(entry)
210
+ if path in added:
211
+ continue
212
+ # Require the symbol existed at baseline (was live then, dead now).
213
+ if not base_index_norm or key not in base_index_norm:
214
+ continue
215
+ # Defining line added in this task's diff → new symbol, not stranded.
216
+ if line is not None and line in added_lines.get(path, ()):
217
+ continue
218
+ label = name or entry
219
+ gaps.append(Gap(
220
+ id=gap_id(task_id, "STRANDSYM"),
221
+ severity="high" if blocking else "medium",
222
+ gap_type="stranded_code",
223
+ task_id=task_id,
224
+ description=(
225
+ f"Pre-existing symbol `{label}` at {path}"
226
+ f"{':' + str(line) if line else ''} became unreferenced "
227
+ "during this task (lost its last caller)."
228
+ ),
229
+ evidence=[entry, f"symbol:{label}", "regression:dead_symbol"],
230
+ recommended_fix=(
231
+ "Restore the import/call that referenced this symbol, or delete "
232
+ "the stranded module/symbol if it is intentionally unused."
233
+ ),
234
+ blocking=blocking,
235
+ file=path,
236
+ line=line,
237
+ ))
238
+ except Exception:
239
+ logger.debug("detect_liveness_regressions failed; degrading to zero gaps", exc_info=True)
240
+ return []
241
+ return gaps
242
+
243
+
244
+ def load_liveness_baseline(project_root: Path, task_id: str) -> Optional[dict]:
245
+ """Load checkout-time liveness baseline; None when absent, unreadable, or incomplete."""
246
+ path = project_root / ".devcouncil" / "liveness_baseline" / f"{task_id}.json"
247
+ if not path.is_file():
248
+ return None
249
+ try:
250
+ from devcouncil.utils.json_persist import read_json
251
+
252
+ data = read_json(path)
253
+ if not isinstance(data, dict):
254
+ return None
255
+ if data.get("complete") is not True:
256
+ logger.warning(
257
+ "liveness baseline for %s is incomplete; treating as missing",
258
+ task_id,
259
+ )
260
+ return None
261
+ return data
262
+ except Exception:
263
+ logger.debug("failed to load liveness baseline for %s", task_id, exc_info=True)
264
+ return None
265
+
266
+
267
+ def delete_liveness_baseline(project_root: Path, task_id: str) -> bool:
268
+ """Remove the checkout baseline for ``task_id``. Returns True when deleted."""
269
+ path = project_root / ".devcouncil" / "liveness_baseline" / f"{task_id}.json"
270
+ try:
271
+ if path.is_file():
272
+ path.unlink()
273
+ return True
274
+ except OSError:
275
+ logger.debug("failed to delete liveness baseline for %s", task_id, exc_info=True)
276
+ return False
277
+
278
+
279
+ def snapshot_liveness_baseline(
280
+ project_root: Path,
281
+ task_id: str,
282
+ *,
283
+ reset: bool = False,
284
+ ) -> Optional[Path]:
285
+ """Write-once uncapped liveness snapshot for ``task_id``.
286
+
287
+ Skips when a complete baseline already exists unless ``reset=True``. Writes
288
+ only after a full successful scan and marks ``complete: true`` so partial
289
+ failures never become ratchet inputs. Returns the path, or None on failure.
290
+ Never raises.
291
+ """
292
+ try:
293
+ from devcouncil.indexing.repo_mapper import RepoMapper
294
+ from devcouncil.utils.json_persist import write_json
295
+
296
+ out_dir = project_root / ".devcouncil" / "liveness_baseline"
297
+ out_dir.mkdir(parents=True, exist_ok=True)
298
+ out_path = out_dir / f"{task_id}.json"
299
+
300
+ if out_path.is_file() and not reset:
301
+ try:
302
+ from devcouncil.utils.json_persist import read_json
303
+
304
+ existing = read_json(out_path)
305
+ if isinstance(existing, dict) and existing.get("complete") is True:
306
+ return out_path
307
+ except Exception:
308
+ pass
309
+ # Incomplete/corrupt on disk — fall through and rewrite.
310
+
311
+ mapper = RepoMapper(project_root)
312
+ snap = mapper.liveness_snapshot()
313
+ from devcouncil.indexing.wiring import LIVENESS_SCAN_VERSION
314
+
315
+ unreliable = bool(snap.get("liveness_unreachable_unreliable")) or not _as_list(
316
+ snap.get("entry_roots")
317
+ )
318
+ # Empty-root scans must not become ratchet baselines (would flood or
319
+ # falsely look "clean" after fail-soft unreachable=[]).
320
+ payload: dict[str, Any] = {
321
+ "unwired_candidates": _as_list(snap.get("unwired_candidates")),
322
+ "unreachable_files": _as_list(snap.get("unreachable_files")),
323
+ "dead_symbol_candidates": _as_list(snap.get("dead_symbol_candidates")),
324
+ "entry_roots": _as_list(snap.get("entry_roots")),
325
+ "symbol_index": _as_list(snap.get("symbol_index")),
326
+ "liveness_unreachable_unreliable": unreliable,
327
+ "generated_head": mapper._git_head(),
328
+ "source": "fresh_scan",
329
+ "scan_version": LIVENESS_SCAN_VERSION,
330
+ "complete": not unreliable,
331
+ }
332
+ write_json(out_path, payload)
333
+ return out_path if not unreliable else None
334
+ except Exception:
335
+ logger.debug("snapshot_liveness_baseline failed for %s", task_id, exc_info=True)
336
+ return None
@@ -0,0 +1,104 @@
1
+ """Orphan-diff detection helpers extracted from Verifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Callable, List, Tuple
9
+
10
+ from devcouncil.domain.gap import Gap
11
+ from devcouncil.domain.task import Task
12
+ from devcouncil.indexing.wiring import is_test_path
13
+ from devcouncil.verification.stub_detector import detect_stubs
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def classify_change_paths(
19
+ project_root: Path,
20
+ changed_files: List[str],
21
+ get_untracked_files: Callable[[], List[str]],
22
+ ) -> Tuple[List[str], List[str]]:
23
+ """Return (added, deleted) paths intersecting changed_files."""
24
+ changed_set = set(changed_files)
25
+ added = set(get_untracked_files())
26
+ deleted: set[str] = set()
27
+ try:
28
+ from devcouncil.utils.proc import GIT_TIMEOUT
29
+
30
+ output = subprocess.check_output(
31
+ ["git", "diff", "HEAD", "--name-status"],
32
+ cwd=project_root,
33
+ stderr=subprocess.DEVNULL,
34
+ timeout=GIT_TIMEOUT,
35
+ ).decode("utf-8", errors="replace").splitlines()
36
+ for line in output:
37
+ parts = line.split("\t")
38
+ if len(parts) < 2:
39
+ continue
40
+ status = parts[0]
41
+ path = parts[-1].replace("\\", "/")
42
+ if status.startswith("A"):
43
+ added.add(path)
44
+ elif status.startswith("D"):
45
+ deleted.add(path)
46
+ except Exception as e:
47
+ logger.debug("Failed to classify changed files: %s", e)
48
+ return sorted(added & changed_set), sorted(deleted & changed_set)
49
+
50
+
51
+ def detect_orphan_diff_gaps(
52
+ *,
53
+ task: Task,
54
+ changed_files: List[str],
55
+ planned_paths: set[str],
56
+ diff_content: str,
57
+ project_root: Path,
58
+ get_untracked_files: Callable[[], List[str]],
59
+ next_gap_id: Callable[[str, str], str],
60
+ classify_fn: Callable[[List[str]], Tuple[List[str], List[str]]] | None = None,
61
+ ) -> List[Gap]:
62
+ """Detect orphan-diff gaps for files changed outside the task plan."""
63
+ gaps: List[Gap] = []
64
+ if classify_fn is not None:
65
+ orphan_added, _orphan_deleted = classify_fn(changed_files)
66
+ else:
67
+ orphan_added, _orphan_deleted = classify_change_paths(
68
+ project_root, changed_files, get_untracked_files,
69
+ )
70
+ assert_free_test_files: set[str] = set()
71
+ if diff_content:
72
+ for finding in detect_stubs(project_root, diff_content, honor_allow_stub=False):
73
+ if "no assertions" in finding.reason:
74
+ assert_free_test_files.add(finding.file)
75
+ for cf in changed_files:
76
+ if cf not in planned_paths:
77
+ new_test_file = (
78
+ cf in orphan_added
79
+ and is_test_path(cf)
80
+ and cf not in assert_free_test_files
81
+ )
82
+ gaps.append(Gap(
83
+ id=next_gap_id(task.id, f"ORPHAN-{cf}"),
84
+ severity="medium" if new_test_file else "high",
85
+ gap_type="orphan_diff",
86
+ task_id=task.id,
87
+ description=(
88
+ f"New test file {cf} was added but not planned for this task "
89
+ "(advisory: added tests cannot change shipped behavior)."
90
+ if new_test_file else
91
+ f"File {cf} was modified but not planned for this task."
92
+ ),
93
+ evidence=[cf],
94
+ recommended_fix=(
95
+ f"Append {cf} with `dev scope update <task_id> --lease-token <token> "
96
+ f"--planned-file {cf}` (or fold the tests into a planned test file)."
97
+ if new_test_file else
98
+ f"Revert changes to {cf} or append it with "
99
+ f"`dev scope update <task_id> --lease-token <token> --planned-file {cf}`."
100
+ ),
101
+ blocking=not new_test_file,
102
+ file=cf,
103
+ ))
104
+ return gaps