devcouncil 0.3.1 → 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
@@ -37,6 +37,27 @@ _CONSOLE_HANDLER_TAG = "devcouncil.console"
37
37
  _LOG_FORMAT = "%(asctime)s %(levelname)-7s [%(name)s] %(message)s"
38
38
  _DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
39
39
 
40
+ # Last (console_level, log_path) we announced, so repeat configure_logging()
41
+ # calls in the same process don't spam identical "Logging configured" lines
42
+ # into the DEBUG file (observed: ~16% of a real log was this one line).
43
+ _last_announced_config: Optional[tuple] = None
44
+
45
+ # Messages already emitted via warn_once() in this process.
46
+ _warned_once: set = set()
47
+
48
+
49
+ def warn_once(target_logger: logging.Logger, message: str) -> None:
50
+ """Emit ``message`` at WARNING at most once per process.
51
+
52
+ For per-task/per-call code paths that detect a PROCESS-level condition (e.g.
53
+ a risky config value): the first occurrence is signal, the 20th identical
54
+ line in one run is log spam that buries real warnings.
55
+ """
56
+ if message in _warned_once:
57
+ return
58
+ _warned_once.add(message)
59
+ target_logger.warning(message)
60
+
40
61
  # Default file location (relative to a project root) for the durable run log.
41
62
  LOG_RELATIVE_PATH = Path(".devcouncil") / "logs" / "devcouncil.log"
42
63
 
@@ -65,6 +86,21 @@ def _resolve_console_level(verbosity: int, quiet: bool, log_level: Optional[str]
65
86
  return _level_from_verbosity(verbosity, quiet)
66
87
 
67
88
 
89
+ def _resolve_log_path(project_root: Optional[Path]) -> Path:
90
+ """Resolve the shared DEBUG log file location.
91
+
92
+ ``DEVCOUNCIL_LOG_DIR`` overrides everything — used by the test suite so
93
+ fixture noise (fake tasks, deliberate failures) never lands in a real
94
+ project's ``.devcouncil/logs/devcouncil.log``, and available to anyone who
95
+ wants logs outside the repo (CI, read-only checkouts).
96
+ """
97
+ override = os.environ.get("DEVCOUNCIL_LOG_DIR")
98
+ if override:
99
+ return Path(override) / "devcouncil.log"
100
+ base = Path(project_root) if project_root is not None else Path.cwd()
101
+ return base / LOG_RELATIVE_PATH
102
+
103
+
68
104
  def _find_tagged_handler(logger: logging.Logger, tag: str) -> Optional[logging.Handler]:
69
105
  for handler in logger.handlers:
70
106
  if getattr(handler, "_devcouncil_tag", None) == tag:
@@ -72,6 +108,35 @@ def _find_tagged_handler(logger: logging.Logger, tag: str) -> Optional[logging.H
72
108
  return None
73
109
 
74
110
 
111
+ class _CurrentStderrHandler(logging.StreamHandler):
112
+ """A stderr handler that resolves ``sys.stderr`` at EMIT time, not at creation.
113
+
114
+ The handler is installed once per process and reused. A plain
115
+ ``logging.StreamHandler()`` snapshots ``sys.stderr`` when constructed; in any
116
+ context that later replaces or closes that stream — test runners, an MCP server
117
+ re-pointing stdio, agents embedding the CLI — every subsequent record then hits a
118
+ dead stream and the logging module prints a ``--- Logging error ---`` report to
119
+ the *current* stderr, polluting agent-facing (e.g. ``--json``) output. Resolving
120
+ the stream per emit keeps console logging bound to whatever stderr is now."""
121
+
122
+ def __init__(self) -> None:
123
+ import sys
124
+
125
+ super().__init__(sys.stderr)
126
+
127
+ @property
128
+ def stream(self):
129
+ import sys
130
+
131
+ return sys.stderr
132
+
133
+ @stream.setter
134
+ def stream(self, value) -> None: # noqa: ARG002 - always current sys.stderr
135
+ # Ignore assignments (StreamHandler.__init__ / setStream): this handler is
136
+ # permanently bound to the CURRENT sys.stderr by design.
137
+ pass
138
+
139
+
75
140
  def configure_logging(
76
141
  project_root: Optional[Path] = None,
77
142
  *,
@@ -97,7 +162,7 @@ def configure_logging(
97
162
  # --- Console handler (stderr) -------------------------------------------
98
163
  console = _find_tagged_handler(root, _CONSOLE_HANDLER_TAG)
99
164
  if console is None:
100
- console = logging.StreamHandler() # defaults to stderr, keeps stdout clean
165
+ console = _CurrentStderrHandler() # emit-time stderr, keeps stdout clean
101
166
  console._devcouncil_tag = _CONSOLE_HANDLER_TAG # type: ignore[attr-defined]
102
167
  console.setFormatter(formatter)
103
168
  root.addHandler(console)
@@ -106,8 +171,7 @@ def configure_logging(
106
171
  # --- Rotating file handler (always DEBUG) -------------------------------
107
172
  log_path: Optional[Path] = None
108
173
  if _find_tagged_handler(root, _FILE_HANDLER_TAG) is None:
109
- base = Path(project_root) if project_root is not None else Path.cwd()
110
- log_path = base / LOG_RELATIVE_PATH
174
+ log_path = _resolve_log_path(project_root)
111
175
  try:
112
176
  log_path.parent.mkdir(parents=True, exist_ok=True)
113
177
  file_handler = RotatingFileHandler(
@@ -125,8 +189,7 @@ def configure_logging(
125
189
  # than crashing the command the user actually asked for.
126
190
  log_path = None
127
191
  else:
128
- base = Path(project_root) if project_root is not None else Path.cwd()
129
- log_path = base / LOG_RELATIVE_PATH
192
+ log_path = _resolve_log_path(project_root)
130
193
 
131
194
  # Quieten chatty third-party loggers on the console; the file still gets them.
132
195
  for noisy in ("httpx", "httpcore", "urllib3", "asyncio"):
@@ -134,11 +197,15 @@ def configure_logging(
134
197
 
135
198
  _install_excepthook()
136
199
 
137
- logging.getLogger(__name__).debug(
138
- "Logging configured (console=%s, file=%s)",
139
- logging.getLevelName(console_level),
140
- log_path,
141
- )
200
+ global _last_announced_config
201
+ config = (console_level, str(log_path))
202
+ if config != _last_announced_config:
203
+ _last_announced_config = config
204
+ logging.getLogger(__name__).debug(
205
+ "Logging configured (console=%s, file=%s)",
206
+ logging.getLevelName(console_level),
207
+ log_path,
208
+ )
142
209
  return log_path
143
210
 
144
211
 
@@ -179,7 +246,7 @@ def set_log_dir(project_root: Path) -> Optional[Path]:
179
246
  is a cheap no-op. Returns the (re)resolved log path, or ``None`` if it could not be
180
247
  created (logging then stays on the previous handler / console).
181
248
  """
182
- target = Path(project_root) / LOG_RELATIVE_PATH
249
+ target = _resolve_log_path(project_root)
183
250
  root = logging.getLogger()
184
251
  existing = _find_tagged_handler(root, _FILE_HANDLER_TAG)
185
252
  if existing is not None:
@@ -31,6 +31,13 @@ openai/gpt-5.5:
31
31
  google/gemini-2.5-flash:
32
32
  prompt_per_1k: 0.0003
33
33
  completion_per_1k: 0.0025
34
+ nvidia/nemotron-3-ultra-550b-a55b:free:
35
+ prompt_per_1k: 0.0
36
+ completion_per_1k: 0.0
37
+ # OpenRouter (Alibaba) Qwen3 Max — default planning/review model.
38
+ qwen/qwen3-max:
39
+ prompt_per_1k: 0.00078
40
+ completion_per_1k: 0.0039
34
41
  # Local Ollama models run on-device and are free. Bare tags (no ``ollama/``
35
42
  # prefix) are listed here so the cost reporter shows $0; the cost.py prefix
36
43
  # guard covers any other ``ollama/`` or ``ollama:`` model id.
@@ -39,6 +39,13 @@ def _safe_trace(
39
39
  """Mirror a stage/step boundary to the JSONL trace, swallowing any error."""
40
40
  if project_root is None:
41
41
  return
42
+ # Never MATERIALIZE a project root that doesn't exist: TraceLogger mkdirs
43
+ # .devcouncil/ under the given path, so tracing under a typo'd/absent
44
+ # --project-root would silently create directories there — and break
45
+ # commands that first check the root's existence (observed: `lsp inspect`
46
+ # reporting a missing root as valid because the stage trace created it).
47
+ if not Path(project_root).exists():
48
+ return
42
49
  try:
43
50
  from devcouncil.telemetry.traces import TraceLogger
44
51
 
@@ -89,11 +96,29 @@ def log_stage(
89
96
  yield
90
97
  except BaseException as exc:
91
98
  elapsed = time.monotonic() - start
92
- logger.error("✖ %s failed (%.2fs): %s", name, elapsed, exc)
99
+ # Control-flow exceptions are NOT stage failures: typer.Exit(1)/SystemExit
100
+ # is how a CLI command reports a legitimate non-zero verdict (e.g. `verify
101
+ # --json` on a blocked task), and logging it as "✖ failed" both misleads
102
+ # and — because the log line prints after the command's JSON — corrupts
103
+ # machine-readable output for any consumer parsing stdout+stderr.
104
+ control_flow = isinstance(exc, (SystemExit, KeyboardInterrupt, GeneratorExit))
105
+ if not control_flow:
106
+ # typer/click CLI exits, matched STRUCTURALLY rather than by isinstance:
107
+ # typer can vendor click (typer._click.exceptions.Exit), which does not
108
+ # subclass the top-level click.exceptions.Exit, so an import-based
109
+ # isinstance check silently misses typer.Exit.
110
+ cls = type(exc)
111
+ control_flow = cls.__name__ in {"Exit", "Abort"} and "click" in (cls.__module__ or "")
112
+ if control_flow:
113
+ logger.debug("· %s exited via control flow (%.2fs): %r", name, elapsed, exc)
114
+ raise
115
+ # %r, not %s: common failures (httpx.ReadTimeout, CancelledError) stringify
116
+ # to an EMPTY message, which logs a useless "failed (0.05s): ."
117
+ logger.error("✖ %s failed (%.2fs): %r", name, elapsed, exc)
93
118
  _safe_trace(
94
119
  project_root,
95
120
  "stage_failed",
96
- {"stage": name, "error": str(exc), "elapsed_s": round(elapsed, 3), **context},
121
+ {"stage": name, "error": repr(exc), "elapsed_s": round(elapsed, 3), **context},
97
122
  run_id=run_id,
98
123
  task_id=task_id,
99
124
  summary=f"Stage failed: {name}",
@@ -1,11 +1,16 @@
1
- import json
1
+ import logging
2
2
  from pathlib import Path
3
- from typing import Dict, Any
3
+ from typing import Dict, Any, cast
4
4
 
5
5
  from devcouncil.telemetry.pricing import pricing_for_model
6
+ from devcouncil.utils.json_persist import read_json, write_json
7
+
8
+ logger = logging.getLogger(__name__)
9
+
6
10
 
7
11
  class TelemetryTracker:
8
12
  def __init__(self, project_root: Path):
13
+ self.project_root = project_root
9
14
  self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
10
15
  # log_usage() reloads the ledger immediately before saving (for concurrent-write
11
16
  # safety), so any value read here would always be overwritten before use. Start
@@ -16,16 +21,41 @@ class TelemetryTracker:
16
21
  def _load(self) -> Dict[str, Any]:
17
22
  if self.log_file.exists():
18
23
  try:
19
- with open(self.log_file, "r") as f:
20
- return json.load(f)
21
- except Exception:
22
- pass
24
+ return cast(Dict[str, Any], read_json(self.log_file))
25
+ except Exception as e:
26
+ # A corrupt ledger silently resets accumulated cost stats on the next save.
27
+ logger.warning("Failed to load telemetry ledger %s, starting fresh: %s", self.log_file, e)
23
28
  return {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
24
29
 
25
30
  def _save(self):
26
31
  self.log_file.parent.mkdir(parents=True, exist_ok=True)
27
- with open(self.log_file, "w") as f:
28
- json.dump(self.stats, f, indent=2)
32
+ write_json(self.log_file, self.stats)
33
+
34
+ def _warn_if_budget_crossed(self, previous_total: float, new_total: float) -> None:
35
+ """Warn (once, at the crossing) when cumulative spend passes the configured budget.
36
+
37
+ WARN-ONLY by design — this never raises and never blocks a run. The budget is
38
+ ``telemetry.cost_budget_usd`` in .devcouncil/config.yaml (``dev cost budget --set X``);
39
+ a missing/invalid config or an unset budget silently disables the check. Warning at
40
+ the crossing (previous < budget <= new) rather than on every over-budget call keeps
41
+ one model-call-per-warning noise out of long runs.
42
+ """
43
+ try:
44
+ from devcouncil.app.config import load_config
45
+
46
+ budget = load_config(self.project_root).telemetry.cost_budget_usd
47
+ except Exception:
48
+ return
49
+ if budget is None or budget <= 0:
50
+ return
51
+ if previous_total < budget <= new_total:
52
+ logger.warning(
53
+ "Cost budget crossed: cumulative model spend $%.4f now exceeds the "
54
+ "configured budget of $%.2f (telemetry.cost_budget_usd). Warn-only — "
55
+ "runs continue. Review with 'dev cost budget' or 'dev cost show'.",
56
+ new_total,
57
+ budget,
58
+ )
29
59
 
30
60
  def log_usage(self, model: str, usage: Dict[str, int], *, local: bool = False):
31
61
  # Re-read the ledger immediately before mutating so the whole load->mutate->save
@@ -35,6 +65,7 @@ class TelemetryTracker:
35
65
  # parallelized SkillOpt _evaluate) each load the same baseline and clobber each
36
66
  # other's entries on save. Reloading here makes the last writer additive, not lossy.
37
67
  self.stats = self._load()
68
+ previous_total = float(self.stats.get("total_cost", 0.0) or 0.0)
38
69
  prompt_tokens = usage.get("prompt_tokens", 0)
39
70
  completion_tokens = usage.get("completion_tokens", 0)
40
71
 
@@ -49,15 +80,21 @@ class TelemetryTracker:
49
80
  completion_tokens / 1000.0
50
81
  ) * rates["completion_per_1k"]
51
82
 
52
- self.stats["total_cost"] += cost
53
- self.stats["total_prompt_tokens"] += prompt_tokens
54
- self.stats["total_completion_tokens"] += completion_tokens
83
+ # .get with defaults: a hand-edited or partially-written telemetry.json must
84
+ # not crash usage logging — missing keys just restart their counters.
85
+ self.stats["total_cost"] = float(self.stats.get("total_cost", 0.0) or 0.0) + cost
86
+ self.stats["total_prompt_tokens"] = int(self.stats.get("total_prompt_tokens", 0) or 0) + prompt_tokens
87
+ self.stats["total_completion_tokens"] = (
88
+ int(self.stats.get("total_completion_tokens", 0) or 0) + completion_tokens
89
+ )
55
90
 
56
- if model not in self.stats["models"]:
57
- self.stats["models"][model] = {"cost": 0.0, "prompt_tokens": 0, "completion_tokens": 0}
91
+ models = self.stats.setdefault("models", {})
92
+ if model not in models:
93
+ models[model] = {"cost": 0.0, "prompt_tokens": 0, "completion_tokens": 0}
58
94
 
59
95
  self.stats["models"][model]["cost"] += cost
60
96
  self.stats["models"][model]["prompt_tokens"] += prompt_tokens
61
97
  self.stats["models"][model]["completion_tokens"] += completion_tokens
62
98
 
63
99
  self._save()
100
+ self._warn_if_budget_crossed(previous_total, float(self.stats.get("total_cost", 0.0) or 0.0))
@@ -2,18 +2,21 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import secrets
5
+ import socketserver
5
6
  import time
6
7
  from importlib import resources
7
8
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
9
  from pathlib import Path
9
- from typing import TYPE_CHECKING
10
+ from typing import TYPE_CHECKING, Any
10
11
  from urllib.parse import urlparse
11
12
 
12
13
  from devcouncil.app.project_status import compute_phase
13
14
  from devcouncil.integrations.actions import apply_integration_target
14
15
  from devcouncil.integrations.check import build_integration_check_report, integration_status_summary
15
16
  from devcouncil.storage.db import get_db
16
- from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
17
+ from devcouncil.utils.json_persist import read_json
18
+ from devcouncil.storage.repositories import ArtifactGraphRepository, GapRepository, StateRepository
19
+ from devcouncil.execution.stop_gate_history import read_events as read_stop_gate_events
17
20
  from devcouncil.telemetry.traces import TraceEvent, read_trace_events_since
18
21
 
19
22
  if TYPE_CHECKING:
@@ -38,7 +41,7 @@ def _load_run_manifest(manifest_path: Path) -> dict | None:
38
41
  if cached is not None and cached[0] == mtime:
39
42
  return dict(cached[1])
40
43
  try:
41
- manifest = json.loads(manifest_path.read_text(encoding="utf-8")) or {}
44
+ manifest = read_json(manifest_path) or {}
42
45
  except (json.JSONDecodeError, OSError):
43
46
  return None
44
47
  _RUN_MANIFEST_CACHE[key] = (mtime, manifest)
@@ -132,6 +135,9 @@ def _invalidate_artifact_graph(project_root: Path) -> None:
132
135
  _TRACE_EVENTS_LIMIT = 50
133
136
  _TRACE_EVENTS_CACHE: dict[str, tuple[int, list[TraceEvent]]] = {}
134
137
 
138
+ _DASHBOARD_GAPS_CAP = 50
139
+ _EMPTY_GAPS_SUMMARY = {"total": 0, "blocking": 0, "items": []}
140
+
135
141
 
136
142
  def _recent_trace_events_cached(project_root: Path) -> list[TraceEvent]:
137
143
  key = str(project_root)
@@ -146,6 +152,28 @@ def _recent_trace_events_cached(project_root: Path) -> list[TraceEvent]:
146
152
  return buffer
147
153
 
148
154
 
155
+ def _dashboard_gaps_summary(session) -> dict:
156
+ all_gaps = GapRepository(session).get_all()
157
+ blocking = [gap for gap in all_gaps if gap.blocking]
158
+ non_blocking = [gap for gap in all_gaps if not gap.blocking]
159
+ items: list[dict[str, Any]] = []
160
+ for gap in blocking + non_blocking:
161
+ if len(items) >= _DASHBOARD_GAPS_CAP:
162
+ break
163
+ description = gap.description
164
+ if len(description) > 200:
165
+ description = description[:197] + "..."
166
+ items.append(
167
+ {
168
+ "blocking": gap.blocking,
169
+ "task_id": gap.task_id or "",
170
+ "gap_type": gap.gap_type,
171
+ "description": description,
172
+ }
173
+ )
174
+ return {"total": len(all_gaps), "blocking": len(blocking), "items": items}
175
+
176
+
149
177
  def logo_svg() -> str:
150
178
  return resources.files("devcouncil.assets").joinpath(LEGACY_LOGO_ASSET).read_text(encoding="utf-8")
151
179
 
@@ -165,6 +193,8 @@ def dashboard_payload(project_root: Path) -> dict:
165
193
  "events": [],
166
194
  "integrations": _integration_summary_cached(project_root),
167
195
  "recent_runs": recent_run_artifacts(project_root),
196
+ "stop_gate_events": read_stop_gate_events(project_root, limit=30),
197
+ "gaps": dict(_EMPTY_GAPS_SUMMARY),
168
198
  }
169
199
  with db.get_session() as session:
170
200
  graph = _artifact_graph_cached(project_root, session)
@@ -179,6 +209,8 @@ def dashboard_payload(project_root: Path) -> dict:
179
209
  "events": [event.model_dump(by_alias=True) for event in _recent_trace_events_cached(project_root)],
180
210
  "integrations": _integration_summary_cached(project_root),
181
211
  "recent_runs": recent_run_artifacts(project_root),
212
+ "stop_gate_events": read_stop_gate_events(project_root, limit=30),
213
+ "gaps": _dashboard_gaps_summary(session),
182
214
  }
183
215
 
184
216
 
@@ -287,6 +319,21 @@ def dashboard_html(token: str = "") -> str:
287
319
  <tbody id="runs"></tbody>
288
320
  </table>
289
321
  </section>
322
+ <section style="grid-column: 1 / -1;">
323
+ <h2>Verification Gaps</h2>
324
+ <p id="gaps-summary" style="font-size: 13px; margin: 0 0 12px;"></p>
325
+ <table>
326
+ <thead><tr><th>Blocking</th><th>Task</th><th>Type</th><th>Description</th></tr></thead>
327
+ <tbody id="gaps"></tbody>
328
+ </table>
329
+ </section>
330
+ <section style="grid-column: 1 / -1;">
331
+ <h2>Stop Gate History</h2>
332
+ <table>
333
+ <thead><tr><th>When</th><th>Session</th><th>Decision</th><th>Task</th><th>Claim</th></tr></thead>
334
+ <tbody id="stop-gate"></tbody>
335
+ </table>
336
+ </section>
290
337
  <section style="grid-column: 1 / -1;"><h2>Recent Trace Events</h2><pre id="events"></pre></section>
291
338
  </main>
292
339
  <script>
@@ -382,6 +429,37 @@ def dashboard_html(token: str = "") -> str:
382
429
  }}));
383
430
 
384
431
  document.getElementById('events').textContent = JSON.stringify(data.events || [], null, 2);
432
+
433
+ const gapsSummary = data.gaps || {{}};
434
+ document.getElementById('gaps-summary').textContent =
435
+ `${{gapsSummary.blocking || 0}} blocking / ${{gapsSummary.total || 0}} total` +
436
+ ((gapsSummary.total || 0) > (gapsSummary.items || []).length
437
+ ? ` (showing first ${{(gapsSummary.items || []).length}})`
438
+ : '');
439
+
440
+ const gapsBody = document.getElementById('gaps');
441
+ gapsBody.replaceChildren(...(gapsSummary.items || []).map(gap => {{
442
+ const row = document.createElement('tr');
443
+ row.appendChild(setText(document.createElement('td'), gap.blocking ? 'yes' : 'no'));
444
+ row.appendChild(setText(document.createElement('td'), gap.task_id || ''));
445
+ row.appendChild(setText(document.createElement('td'), gap.gap_type || ''));
446
+ const desc = (gap.description || '').slice(0, 120);
447
+ row.appendChild(setText(document.createElement('td'), desc));
448
+ return row;
449
+ }}));
450
+
451
+ const stopGateBody = document.getElementById('stop-gate');
452
+ stopGateBody.replaceChildren(...(data.stop_gate_events || []).map(ev => {{
453
+ const row = document.createElement('tr');
454
+ const when = ev.ts ? new Date(ev.ts * 1000).toLocaleString() : '';
455
+ row.appendChild(setText(document.createElement('td'), when));
456
+ row.appendChild(setText(document.createElement('td'), ev.session_id || ''));
457
+ row.appendChild(setText(document.createElement('td'), ev.decision || ''));
458
+ row.appendChild(setText(document.createElement('td'), ev.task_id || ''));
459
+ const claim = (ev.claim || '').slice(0, 120);
460
+ row.appendChild(setText(document.createElement('td'), claim));
461
+ return row;
462
+ }}));
385
463
  }}
386
464
 
387
465
  document.addEventListener('click', event => {{
@@ -402,12 +480,46 @@ def dashboard_html(token: str = "") -> str:
402
480
  </html>"""
403
481
 
404
482
 
405
- def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765) -> None:
483
+ if TYPE_CHECKING:
484
+ _MixinBase = ThreadingHTTPServer
485
+ else:
486
+ _MixinBase = object
487
+
488
+
489
+ class _LoopbackBindMixin(_MixinBase):
490
+ # HTTPServer.server_bind resolves socket.getfqdn(), which can hang for
491
+ # tens of seconds on hosts with slow reverse DNS (seen on macOS CI
492
+ # runners). The dashboard serves loopback only, so skip the lookup.
493
+ def server_bind(self) -> None:
494
+ socketserver.TCPServer.server_bind(self)
495
+ host, port = self.server_address[:2]
496
+ self.server_name = str(host)
497
+ self.server_port = int(port)
498
+
499
+
500
+ class DashboardHTTPServer(_LoopbackBindMixin, ThreadingHTTPServer):
501
+ allow_reuse_address = True
502
+ daemon_threads = True
503
+
504
+
505
+ def run_dashboard(
506
+ project_root: Path,
507
+ host: str = "127.0.0.1",
508
+ port: int = 8765,
509
+ *,
510
+ server_factory: type[ThreadingHTTPServer] | None = None,
511
+ ) -> None:
406
512
  dashboard_token = secrets.token_urlsafe(24)
407
513
 
408
- class DashboardServer(ThreadingHTTPServer):
409
- allow_reuse_address = True
410
- daemon_threads = True
514
+ if server_factory is None:
515
+ # Derive the default at call time from the module-global
516
+ # ThreadingHTTPServer so tests that monkeypatch it still take effect;
517
+ # real use gets the loopback bind that skips the getfqdn hang.
518
+ server_factory = type(
519
+ "DashboardHTTPServer",
520
+ (_LoopbackBindMixin, ThreadingHTTPServer),
521
+ {"allow_reuse_address": True, "daemon_threads": True},
522
+ )
411
523
 
412
524
  class Handler(BaseHTTPRequestHandler):
413
525
  def do_GET(self): # noqa: N802
@@ -483,5 +595,5 @@ def run_dashboard(project_root: Path, host: str = "127.0.0.1", port: int = 8765)
483
595
  def log_message(self, format, *args): # noqa: A002
484
596
  return
485
597
 
486
- server = DashboardServer((host, port), Handler)
598
+ server = server_factory((host, port), Handler)
487
599
  server.serve_forever()
@@ -0,0 +1,58 @@
1
+ """Atomic file-write helpers.
2
+
3
+ State files (checkpoints, handoff manifests, semantic indexes, OKF bundles)
4
+ must never be left half-written by a crash or SIGKILL mid-write. These helpers
5
+ write to a temp file in the same directory and ``os.replace`` it into place,
6
+ which is atomic on POSIX and Windows (same volume).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Any, Union
16
+
17
+ PathLike = Union[str, Path]
18
+
19
+
20
+ def atomic_write_text(path: PathLike, data: str, *, encoding: str = "utf-8") -> None:
21
+ """Atomically replace ``path`` with ``data``. Parent dir must exist."""
22
+ target = Path(path)
23
+ fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=f".{target.name}.", suffix=".tmp")
24
+ try:
25
+ with os.fdopen(fd, "w", encoding=encoding, newline="") as handle:
26
+ handle.write(data)
27
+ handle.flush()
28
+ os.fsync(handle.fileno())
29
+ os.replace(tmp_name, target)
30
+ except BaseException:
31
+ try:
32
+ os.unlink(tmp_name)
33
+ except OSError:
34
+ pass
35
+ raise
36
+
37
+
38
+ def atomic_write_bytes(path: PathLike, data: bytes) -> None:
39
+ """Atomically replace ``path`` with binary ``data``. Parent dir must exist."""
40
+ target = Path(path)
41
+ fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=f".{target.name}.", suffix=".tmp")
42
+ try:
43
+ with os.fdopen(fd, "wb") as handle:
44
+ handle.write(data)
45
+ handle.flush()
46
+ os.fsync(handle.fileno())
47
+ os.replace(tmp_name, target)
48
+ except BaseException:
49
+ try:
50
+ os.unlink(tmp_name)
51
+ except OSError:
52
+ pass
53
+ raise
54
+
55
+
56
+ def atomic_write_json(path: PathLike, payload: Any, *, indent: int = 2, sort_keys: bool = False) -> None:
57
+ """Serialize ``payload`` to JSON and atomically write it to ``path``."""
58
+ atomic_write_text(path, json.dumps(payload, indent=indent, sort_keys=sort_keys, default=str) + "\n")