devcouncil 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (365) hide show
  1. package/README.md +46 -30
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +49 -30
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +960 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +303 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/tasks.py +12 -3
  255. package/src/devcouncil/live/transcripts.py +69 -2
  256. package/src/devcouncil/llm/cache.py +5 -6
  257. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  258. package/src/devcouncil/llm/provider.py +647 -73
  259. package/src/devcouncil/llm/router.py +271 -46
  260. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  261. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  262. package/src/devcouncil/optimization/skillopt.py +9 -5
  263. package/src/devcouncil/planning/arbiter_service.py +12 -3
  264. package/src/devcouncil/planning/correction_manifest.py +107 -10
  265. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  266. package/src/devcouncil/planning/plan_service.py +5 -2
  267. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  268. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  269. package/src/devcouncil/planning/question_conversion.py +56 -0
  270. package/src/devcouncil/planning/spec_service.py +9 -3
  271. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  272. package/src/devcouncil/repo/gitignore.py +1 -2
  273. package/src/devcouncil/reporting/evidence_export.py +124 -0
  274. package/src/devcouncil/reporting/evidence_html.py +210 -0
  275. package/src/devcouncil/reporting/json_report.py +16 -12
  276. package/src/devcouncil/reporting/markdown_report.py +38 -9
  277. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  278. package/src/devcouncil/reporting/report_builder.py +40 -4
  279. package/src/devcouncil/reporting/task_provenance.py +42 -0
  280. package/src/devcouncil/reporting/verdict.py +75 -0
  281. package/src/devcouncil/skills/library/README.md +1 -0
  282. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  283. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  285. package/src/devcouncil/skills/registry.py +43 -12
  286. package/src/devcouncil/storage/db.py +57 -11
  287. package/src/devcouncil/storage/models.py +6 -0
  288. package/src/devcouncil/storage/native.py +5 -3
  289. package/src/devcouncil/storage/repositories.py +50 -18
  290. package/src/devcouncil/telemetry/context.py +28 -0
  291. package/src/devcouncil/telemetry/cost.py +4 -5
  292. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  293. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  294. package/src/devcouncil/telemetry/stages.py +27 -2
  295. package/src/devcouncil/telemetry/tracker.py +50 -13
  296. package/src/devcouncil/ui/dashboard.py +120 -8
  297. package/src/devcouncil/utils/fsio.py +58 -0
  298. package/src/devcouncil/utils/git_snapshot.py +112 -0
  299. package/src/devcouncil/utils/json_persist.py +53 -0
  300. package/src/devcouncil/utils/proc.py +89 -0
  301. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  302. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  303. package/src/devcouncil/verification/checks/__init__.py +41 -0
  304. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  305. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  306. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  307. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  308. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  309. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  310. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  311. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  312. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  313. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  314. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  315. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  316. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  317. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  318. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  319. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  320. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  321. package/src/devcouncil/verification/checks/wiring.py +216 -0
  322. package/src/devcouncil/verification/claims/__init__.py +23 -0
  323. package/src/devcouncil/verification/claims/checks.py +395 -0
  324. package/src/devcouncil/verification/claims/mapper.py +168 -0
  325. package/src/devcouncil/verification/claims/models.py +39 -0
  326. package/src/devcouncil/verification/claims/transcript.py +92 -0
  327. package/src/devcouncil/verification/claims/verdict.py +88 -0
  328. package/src/devcouncil/verification/command_evidence.py +170 -0
  329. package/src/devcouncil/verification/command_malformation.py +147 -0
  330. package/src/devcouncil/verification/command_runner.py +164 -0
  331. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  332. package/src/devcouncil/verification/diff_coverage.py +151 -0
  333. package/src/devcouncil/verification/difficulty.py +296 -0
  334. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  335. package/src/devcouncil/verification/gap_ids.py +63 -0
  336. package/src/devcouncil/verification/gate_cache.py +194 -0
  337. package/src/devcouncil/verification/gate_selector.py +344 -0
  338. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  339. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  340. package/src/devcouncil/verification/incremental_check.py +241 -0
  341. package/src/devcouncil/verification/next_actions.py +60 -1
  342. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  343. package/src/devcouncil/verification/sandbox.py +38 -11
  344. package/src/devcouncil/verification/stub_detector.py +369 -0
  345. package/src/devcouncil/verification/test_resolver.py +67 -1
  346. package/src/devcouncil/verification/verifier.py +137 -1666
  347. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  348. package/src/devcouncil/verification/verify_setup.py +176 -0
  349. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  350. package/src/semantic_layer/__init__.py +58 -0
  351. package/src/semantic_layer/benchmark.py +75 -0
  352. package/src/semantic_layer/cache.py +290 -0
  353. package/src/semantic_layer/compressor.py +137 -0
  354. package/src/semantic_layer/config.py +75 -0
  355. package/src/semantic_layer/embeddings.py +69 -0
  356. package/src/semantic_layer/llm_backends.py +99 -0
  357. package/src/semantic_layer/pipeline.py +111 -0
  358. package/src/semantic_layer/router.py +128 -0
  359. package/src/semantic_layer/tuner.py +72 -0
  360. package/uv.lock +973 -9
  361. package/src/devcouncil/artifacts/migrations.py +0 -20
  362. package/src/devcouncil/artifacts/schemas.py +0 -23
  363. package/src/devcouncil/artifacts/serializer.py +0 -21
  364. package/src/devcouncil/integrations/gitnexus.py +0 -70
  365. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -0,0 +1,194 @@
1
+ """Content-hash keyed cache of green gate results for incremental verification.
2
+
3
+ Persisted at ``.devcouncil/cache/gate_results.json``. A gate is cached green only when
4
+ it *passed*; the cache key is a hash over the command string plus the byte content of
5
+ the gate's declared inputs (see :class:`~devcouncil.verification.gate_selector.GateSpec`).
6
+ On the next run, a gate whose input hash matches a stored green entry is skipped — so an
7
+ edit to file A never re-runs the gate that only depends on file B.
8
+
9
+ Failing results are recorded too (so the sidecar can show a cached FAIL without a
10
+ re-run), but ``is_green`` is the only thing that authorizes a *skip*; a cached failure
11
+ is always re-run so a fix is picked up immediately.
12
+
13
+ Best-effort and crash-safe: a corrupt/absent cache degrades to "nothing cached" (every
14
+ gate runs), and writes go through the atomic JSON helper. This cache is an optimization,
15
+ never a source of truth — the full verify path does not consult it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import logging
22
+ import time
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING
26
+
27
+ from devcouncil.utils.json_persist import read_json, write_json
28
+
29
+ if TYPE_CHECKING:
30
+ from devcouncil.verification.gate_selector import GateSpec
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _CACHE_VERSION = 1
35
+ _ABSENT = "\0absent\0"
36
+
37
+ _CONFIG_FILES = (
38
+ "pyproject.toml",
39
+ "ruff.toml",
40
+ ".ruff.toml",
41
+ "mypy.ini",
42
+ "pyrightconfig.json",
43
+ "tsconfig.json",
44
+ "package-lock.json",
45
+ "uv.lock",
46
+ "poetry.lock",
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class _Entry:
52
+ input_hash: str
53
+ passed: bool
54
+ summary: str
55
+ updated_at: float
56
+
57
+
58
+ class GateResultCache:
59
+ """Load/query/update the on-disk green-gate cache.
60
+
61
+ Instantiate once per verify pass; call :meth:`is_green` to decide skips, then
62
+ :meth:`record` after running a gate, and :meth:`save` once at the end.
63
+ """
64
+
65
+ def __init__(self, project_root: Path):
66
+ self.project_root = Path(project_root)
67
+ self._entries: dict[str, _Entry] = {}
68
+ self._loaded = False
69
+
70
+ @property
71
+ def path(self) -> Path:
72
+ return self.project_root / ".devcouncil" / "cache" / "gate_results.json"
73
+
74
+ def load(self) -> "GateResultCache":
75
+ self._loaded = True
76
+ self._entries = {}
77
+ if not self.path.exists():
78
+ return self
79
+ try:
80
+ data = read_json(self.path)
81
+ except Exception as exc:
82
+ logger.debug("gate cache unreadable (%s); treating as empty", exc)
83
+ return self
84
+ if not isinstance(data, dict) or data.get("version") != _CACHE_VERSION:
85
+ return self
86
+ gates = data.get("gates")
87
+ if not isinstance(gates, dict):
88
+ return self
89
+ for name, raw in gates.items():
90
+ if not isinstance(raw, dict) or "input_hash" not in raw:
91
+ continue
92
+ self._entries[name] = _Entry(
93
+ input_hash=str(raw.get("input_hash", "")),
94
+ passed=bool(raw.get("passed", False)),
95
+ summary=str(raw.get("summary", "")),
96
+ updated_at=float(raw.get("updated_at", 0.0) or 0.0),
97
+ )
98
+ return self
99
+
100
+ def _ensure_loaded(self) -> None:
101
+ if not self._loaded:
102
+ self.load()
103
+
104
+ def _file_hash(self, rel_path: str) -> str:
105
+ """SHA-256 of a file's bytes, or a sentinel for a missing/unreadable file.
106
+
107
+ Missing is a distinct value (not skipped) so that deleting or creating an input
108
+ changes the hash and correctly invalidates a previously-green gate."""
109
+ fp = self.project_root / rel_path
110
+ try:
111
+ if not fp.is_file():
112
+ return _ABSENT
113
+ digest = hashlib.sha256()
114
+ with fp.open("rb") as handle:
115
+ for chunk in iter(lambda: handle.read(65536), b""):
116
+ digest.update(chunk)
117
+ return digest.hexdigest()
118
+ except OSError:
119
+ return _ABSENT
120
+
121
+ def _config_fingerprint(self) -> str:
122
+ """Hash mtime+size of known project config files that affect gate behavior."""
123
+ digest = hashlib.sha256()
124
+ for name in _CONFIG_FILES:
125
+ fp = self.project_root / name
126
+ digest.update(name.encode("utf-8"))
127
+ digest.update(b"=")
128
+ try:
129
+ if not fp.is_file():
130
+ digest.update(_ABSENT.encode("utf-8"))
131
+ else:
132
+ stat = fp.stat()
133
+ digest.update(f"{stat.st_mtime_ns}:{stat.st_size}".encode("utf-8"))
134
+ except OSError:
135
+ digest.update(_ABSENT.encode("utf-8"))
136
+ digest.update(b"\0")
137
+ return digest.hexdigest()
138
+
139
+ def input_hash(self, gate: "GateSpec") -> str:
140
+ """Stable hash over the gate command, config fingerprint, and input file contents."""
141
+ digest = hashlib.sha256()
142
+ digest.update(gate.command.encode("utf-8"))
143
+ digest.update(b"\0")
144
+ digest.update(self._config_fingerprint().encode("utf-8"))
145
+ digest.update(b"\0")
146
+ for rel_path in sorted(gate.inputs):
147
+ digest.update(rel_path.encode("utf-8"))
148
+ digest.update(b"=")
149
+ digest.update(self._file_hash(rel_path).encode("utf-8"))
150
+ digest.update(b"\0")
151
+ return digest.hexdigest()
152
+
153
+ def is_green(self, gate: "GateSpec") -> bool:
154
+ """True when this gate previously passed with byte-identical inputs and command."""
155
+ self._ensure_loaded()
156
+ entry = self._entries.get(gate.name)
157
+ if entry is None or not entry.passed:
158
+ return False
159
+ return entry.input_hash == self.input_hash(gate)
160
+
161
+ def cached_summary(self, gate: "GateSpec") -> str | None:
162
+ self._ensure_loaded()
163
+ entry = self._entries.get(gate.name)
164
+ return entry.summary if entry is not None else None
165
+
166
+ def record(self, gate: "GateSpec", *, passed: bool, summary: str = "") -> None:
167
+ """Update the in-memory entry for ``gate`` (call :meth:`save` to persist)."""
168
+ self._ensure_loaded()
169
+ self._entries[gate.name] = _Entry(
170
+ input_hash=self.input_hash(gate),
171
+ passed=bool(passed),
172
+ summary=summary,
173
+ updated_at=time.time(),
174
+ )
175
+
176
+ def save(self) -> None:
177
+ """Atomically persist the cache. Best-effort: a write failure is logged, not raised."""
178
+ payload = {
179
+ "version": _CACHE_VERSION,
180
+ "gates": {
181
+ name: {
182
+ "input_hash": entry.input_hash,
183
+ "passed": entry.passed,
184
+ "summary": entry.summary,
185
+ "updated_at": entry.updated_at,
186
+ }
187
+ for name, entry in self._entries.items()
188
+ },
189
+ }
190
+ try:
191
+ self.path.parent.mkdir(parents=True, exist_ok=True)
192
+ write_json(self.path, payload, indent=2, sort_keys=True)
193
+ except Exception as exc:
194
+ logger.debug("failed to persist gate cache: %s", exc)
@@ -0,0 +1,344 @@
1
+ """Map changed paths to the subset of verification gates/commands that need to run.
2
+
3
+ The full verify path (release / ``dev go``) always runs every configured gate. But
4
+ the iterative sidecar (``dev check --watch``) re-runs on every save, and re-running the
5
+ *whole* gate suite on a one-line edit is what makes it feel sluggish. This selector
6
+ narrows the work two ways:
7
+
8
+ 1. **Kind relevance** — a gate keyed to a language stack (``pytest``/``mypy`` → Python,
9
+ ``eslint``/``tsc`` → JS/TS) is skipped entirely when no changed file belongs to that
10
+ stack. A docs-only edit runs no code gate at all.
11
+ 2. **Scope narrowing** — a linter/type-checker invoked over a broad target (``.``,
12
+ ``src``, ``src tests``) is rewritten to target only the touched files of its stack
13
+ ("lint only touched packages"), so the tool parses a handful of files instead of the
14
+ tree. Commands with explicit paths, shell operators, or non-narrowable tools are left
15
+ untouched.
16
+
17
+ Each :class:`GateSpec` also carries the ``inputs`` (the changed files that determine its
18
+ result) so the content-hash cache (:mod:`devcouncil.verification.gate_cache`) can skip a
19
+ gate whose inputs are byte-for-byte unchanged since it last passed.
20
+
21
+ Pure/​deterministic and dependency-free by design: it takes changed paths + a command
22
+ map and returns specs. Nothing here shells out — running the gates stays the caller's job.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import shlex
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Mapping, Sequence
31
+
32
+ from devcouncil.indexing.subsystem_map import dependents_of
33
+
34
+ # Extensions we treat as "code" for the purpose of deciding whether any code gate is
35
+ # relevant at all. Docs/config-only diffs skip the code gates.
36
+ _CODE_EXTS = frozenset({
37
+ ".py", ".pyi", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
38
+ ".go", ".rs", ".java", ".kt", ".rb", ".cs", ".cpp", ".cc", ".c", ".h", ".hpp",
39
+ ".swift", ".scala", ".php",
40
+ })
41
+
42
+ _PYTHON_EXTS = frozenset({".py", ".pyi"})
43
+ _JS_TS_EXTS = frozenset({".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"})
44
+
45
+ # Project config paths that affect tool behaviour. A config-only diff still selects the
46
+ # gates for the matching stack so ``dev check --watch`` re-runs lint/typecheck after
47
+ # rule changes instead of falling back to the evidence gate.
48
+ _CONFIG_PATHS = frozenset({
49
+ "pyproject.toml", "ruff.toml", ".ruff.toml", "mypy.ini", "setup.cfg",
50
+ "pyrightconfig.json", "tsconfig.json", "tsconfig.base.json",
51
+ "package.json", "package-lock.json", "uv.lock", "poetry.lock", "Pipfile",
52
+ ".python-version",
53
+ })
54
+
55
+ # Config basename → stack it belongs to. Names in ``_CONFIG_PATHS`` but absent here apply
56
+ # to every stack (e.g. lockfiles that can affect multiple toolchains).
57
+ _CONFIG_STACK: dict[str, str] = {
58
+ "pyproject.toml": "python",
59
+ "ruff.toml": "python",
60
+ ".ruff.toml": "python",
61
+ "mypy.ini": "python",
62
+ "setup.cfg": "python",
63
+ "pyrightconfig.json": "python",
64
+ "Pipfile": "python",
65
+ ".python-version": "python",
66
+ "uv.lock": "python",
67
+ "poetry.lock": "python",
68
+ "tsconfig.json": "js_ts",
69
+ "tsconfig.base.json": "js_ts",
70
+ "package-lock.json": "js_ts",
71
+ }
72
+
73
+ # Tool → the stack it belongs to. A command whose resolved tool is here is only
74
+ # relevant when a changed file matches that stack. Tools not listed are treated as
75
+ # stack-agnostic (always relevant; inputs = every changed file).
76
+ _TOOL_STACK: dict[str, str] = {
77
+ # Python
78
+ "pytest": "python", "mypy": "python", "pyright": "python", "ruff": "python",
79
+ "black": "python", "flake8": "python", "isort": "python", "pylint": "python",
80
+ "pyflakes": "python", "pycodestyle": "python", "autopep8": "python", "yapf": "python",
81
+ "bandit": "python", "unittest": "python",
82
+ # JS / TS
83
+ "eslint": "js_ts", "tsc": "js_ts", "prettier": "js_ts", "jest": "js_ts",
84
+ "vitest": "js_ts", "biome": "js_ts", "standard": "js_ts", "stylelint": "js_ts",
85
+ }
86
+
87
+ _STACK_EXTS: dict[str, frozenset[str]] = {
88
+ "python": _PYTHON_EXTS,
89
+ "js_ts": _JS_TS_EXTS,
90
+ }
91
+
92
+ # Tools whose positional path arguments can be safely narrowed to specific files
93
+ # without changing their meaning. ``tsc`` is intentionally excluded: passing files to
94
+ # ``tsc`` disables the project's tsconfig, silently changing the check.
95
+ _NARROWABLE_TOOLS = frozenset({
96
+ "ruff", "black", "flake8", "isort", "pylint", "pyflakes", "pycodestyle",
97
+ "autopep8", "yapf", "bandit", "mypy", "pyright", "eslint", "prettier", "stylelint",
98
+ })
99
+
100
+ # Broad positional targets a narrowable linter is commonly pointed at. When a command's
101
+ # only path args are drawn from this set, they are replaced with the touched files.
102
+ _BROAD_TARGETS = frozenset({".", "./", "src", "src/", "tests", "tests/", "test", "test/", "lib", "lib/", "app", "app/"})
103
+
104
+ # Command-runner wrappers to peel off before resolving the underlying tool.
105
+ _WRAPPERS = frozenset({"npx", "poetry", "uv", "pdm", "hatch", "rye", "pipenv"})
106
+
107
+ # Subcommand tokens that are part of the invocation, not a path argument, for tools
108
+ # that take one (``ruff check .`` → ``check`` is the subcommand). Kept verbatim when a
109
+ # command is narrowed so the meaning is preserved.
110
+ _TOOL_SUBCOMMANDS: dict[str, frozenset[str]] = {
111
+ "ruff": frozenset({"check", "format"}),
112
+ }
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class GateSpec:
117
+ """One unit of verification work selected for the current changed-file set.
118
+
119
+ ``name`` is a stable identifier used as the cache key. ``kind`` is the gate family
120
+ (``lint``/``typecheck``/``test``). ``command`` is the (possibly narrowed) shell
121
+ command to run. ``inputs`` are the changed files whose content determines the
122
+ result — the content-hash cache keys on them plus the command string.
123
+ """
124
+
125
+ name: str
126
+ kind: str
127
+ command: str
128
+ inputs: tuple[str, ...] = ()
129
+ stack: str | None = None
130
+ narrowed: bool = False
131
+ reason: str = ""
132
+
133
+
134
+ @dataclass
135
+ class GateSelection:
136
+ """Result of :func:`select_gates`: the gates to run and the ones skipped (with why)."""
137
+
138
+ gates: list[GateSpec] = field(default_factory=list)
139
+ skipped: list[tuple[str, str]] = field(default_factory=list) # (command, reason)
140
+
141
+ @property
142
+ def commands(self) -> list[str]:
143
+ return [g.command for g in self.gates]
144
+
145
+
146
+ def _norm(path: str) -> str:
147
+ return path.replace("\\", "/")
148
+
149
+
150
+ def _ext(path: str) -> str:
151
+ return Path(path).suffix.lower()
152
+
153
+
154
+ def _resolve_tool(tokens: Sequence[str]) -> tuple[str | None, int]:
155
+ """Return (tool_name, index_of_first_positional_after_tool) for a command's tokens.
156
+
157
+ Peels ``python -m``, ``npm run``, and runner wrappers (``poetry run`` etc.) so the
158
+ stack can be inferred from the real tool. ``tool`` is lower-cased and stripped of
159
+ any path prefix / version pin. Index points just past the tool token.
160
+ """
161
+ i = 0
162
+ n = len(tokens)
163
+ while i < n:
164
+ tok = tokens[i]
165
+ if tok in {"python", "python3", "py"} and i + 1 < n and tokens[i + 1] == "-m":
166
+ i += 2
167
+ continue
168
+ if tok in {"npm", "pnpm", "yarn"}:
169
+ # `npm run lint` / `npm test` — the script name is not the tool; treat as
170
+ # agnostic-but-named by the manager. Return the manager so callers don't
171
+ # try to narrow it.
172
+ return tok, n
173
+ if tok in _WRAPPERS:
174
+ i += 1
175
+ if i < n and tokens[i] == "run":
176
+ i += 1
177
+ continue
178
+ break
179
+ if i >= n:
180
+ return None, n
181
+ raw = tokens[i]
182
+ tool = raw.replace("\\", "/").split("/")[-1].split("==")[0].lower()
183
+ return tool, i + 1
184
+
185
+
186
+ def _stack_for_tool(tool: str | None) -> str | None:
187
+ if tool is None:
188
+ return None
189
+ return _TOOL_STACK.get(tool)
190
+
191
+
192
+ def _is_config_path(path: str) -> bool:
193
+ return Path(path).name in _CONFIG_PATHS
194
+
195
+
196
+ def _config_paths_for_stack(changed: Sequence[str], stack: str | None) -> list[str]:
197
+ """Config files in ``changed`` that apply to ``stack`` (or any stack when unknown)."""
198
+ result: list[str] = []
199
+ for path in changed:
200
+ if not _is_config_path(path):
201
+ continue
202
+ name = Path(path).name
203
+ cfg_stack = _CONFIG_STACK.get(name)
204
+ if stack is None or cfg_stack is None or cfg_stack == stack:
205
+ result.append(path)
206
+ return result
207
+
208
+
209
+ def _files_for_stack(changed: Sequence[str], stack: str | None) -> list[str]:
210
+ if stack is None:
211
+ return list(changed)
212
+ exts = _STACK_EXTS.get(stack, frozenset())
213
+ return [p for p in changed if _ext(p) in exts]
214
+
215
+
216
+ def _relevant_files(changed: Sequence[str], stack: str | None) -> list[str]:
217
+ """Changed files that make a stack gate worth running (code and/or config)."""
218
+ code = _files_for_stack(changed, stack)
219
+ config = _config_paths_for_stack(changed, stack)
220
+ if code:
221
+ return sorted(set(code) | set(config))
222
+ return sorted(config)
223
+
224
+
225
+ def _expand_narrow_targets(
226
+ code_files: Sequence[str],
227
+ stack: str | None,
228
+ repo_map: Mapping | None,
229
+ ) -> list[str]:
230
+ """Add direct import dependents so narrowed type-checkers catch cross-file errors."""
231
+ if not code_files or not repo_map:
232
+ return list(code_files)
233
+ exts = _STACK_EXTS.get(stack or "", frozenset())
234
+ expanded = set(code_files)
235
+ for path in code_files:
236
+ for dependent in dependents_of(path, repo_map):
237
+ if _ext(dependent) in exts:
238
+ expanded.add(_norm(dependent))
239
+ return sorted(expanded)
240
+
241
+
242
+ def _narrow_command(command: str, tool: str, tool_idx: int, targets: Sequence[str]) -> str | None:
243
+ """Rewrite ``command`` so its broad positional path args become ``targets``.
244
+
245
+ Returns the narrowed command string, or ``None`` when narrowing is unsafe (shell
246
+ operators present, no broad target found, or the command already names specific
247
+ paths). Flags/options are preserved; only bare broad targets are replaced.
248
+ """
249
+ if any(op in command for op in ("&&", "||", "|", ";", ">", "<", "$(", "`")):
250
+ return None
251
+ try:
252
+ tokens = shlex.split(command)
253
+ except ValueError:
254
+ return None
255
+ head = tokens[:tool_idx]
256
+ rest = tokens[tool_idx:]
257
+ subcommands = _TOOL_SUBCOMMANDS.get(tool, frozenset())
258
+ # ``prefix`` keeps flags and the tool's subcommand verbatim; ``positionals`` are the
259
+ # path-like args we consider replacing.
260
+ prefix: list[str] = []
261
+ positionals: list[str] = []
262
+ for tok in rest:
263
+ if tok.startswith("-"):
264
+ prefix.append(tok)
265
+ elif not positionals and tok in subcommands:
266
+ prefix.append(tok)
267
+ else:
268
+ positionals.append(tok)
269
+ # Only narrow when every positional is a broad target. If the command already names
270
+ # a specific file/dir (or a flag value we don't understand), respect it — the author
271
+ # scoped it deliberately.
272
+ if not positionals or any(p not in _BROAD_TARGETS for p in positionals):
273
+ return None
274
+ narrowed = head + prefix + list(targets)
275
+ return " ".join(shlex.quote(t) if (" " in t) else t for t in narrowed)
276
+
277
+
278
+ def select_gates(
279
+ changed_files: Sequence[str],
280
+ commands: Mapping[str, Sequence[str]],
281
+ *,
282
+ narrow: bool = True,
283
+ repo_map: Mapping | None = None,
284
+ ) -> GateSelection:
285
+ """Select the gates to run for ``changed_files`` from a ``kind -> [command]`` map.
286
+
287
+ ``commands`` keys are gate kinds (``test``/``lint``/``typecheck``); each maps to the
288
+ configured command strings. A command is dropped when it targets a stack none of the
289
+ changed files belong to, and narrowed to the touched files when it is a broad-target
290
+ linter/type-checker and ``narrow`` is set. Order of kinds and commands is preserved.
291
+ """
292
+ changed = [_norm(p) for p in changed_files if p and p.strip()]
293
+ selection = GateSelection()
294
+ if not changed:
295
+ return selection
296
+
297
+ seen_names: set[str] = set()
298
+ for kind in ("lint", "typecheck", "test"):
299
+ for command in commands.get(kind, []) or []:
300
+ command = command.strip()
301
+ if not command:
302
+ continue
303
+ try:
304
+ tokens = shlex.split(command)
305
+ except ValueError:
306
+ tokens = command.split()
307
+ tool, tool_idx = _resolve_tool(tokens)
308
+ stack = _stack_for_tool(tool)
309
+ relevant = _relevant_files(changed, stack) if stack is not None else list(changed)
310
+ if stack is not None and not relevant:
311
+ selection.skipped.append(
312
+ (command, f"no changed file in the '{stack}' stack")
313
+ )
314
+ continue
315
+ final_command = command
316
+ narrowed = False
317
+ input_files = list(relevant if stack is not None else changed)
318
+ if narrow and tool in _NARROWABLE_TOOLS and stack is not None:
319
+ code_targets = _files_for_stack(relevant, stack)
320
+ narrow_targets = code_targets
321
+ if tool in {"mypy", "pyright"} and code_targets:
322
+ narrow_targets = _expand_narrow_targets(code_targets, stack, repo_map)
323
+ if narrow_targets:
324
+ candidate = _narrow_command(command, tool, tool_idx, narrow_targets)
325
+ if candidate is not None:
326
+ final_command = candidate
327
+ narrowed = True
328
+ config_in_relevant = [p for p in relevant if _is_config_path(p)]
329
+ input_files = sorted(set(narrow_targets) | set(config_in_relevant))
330
+ inputs = tuple(sorted(input_files))
331
+ name = f"{kind}:{final_command}"
332
+ if name in seen_names:
333
+ continue
334
+ seen_names.add(name)
335
+ selection.gates.append(GateSpec(
336
+ name=name,
337
+ kind=kind,
338
+ command=final_command,
339
+ inputs=inputs,
340
+ stack=stack,
341
+ narrowed=narrowed,
342
+ reason="narrowed to touched files" if narrowed else "",
343
+ ))
344
+ return selection