devcouncil 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/README.md +92 -33
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +56 -40
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +964 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +281 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +87 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +103 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +69 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +53 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +325 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/summary.py +21 -3
  255. package/src/devcouncil/live/tasks.py +12 -3
  256. package/src/devcouncil/live/transcripts.py +69 -2
  257. package/src/devcouncil/llm/cache.py +5 -6
  258. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  259. package/src/devcouncil/llm/provider.py +647 -73
  260. package/src/devcouncil/llm/router.py +271 -46
  261. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  262. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  263. package/src/devcouncil/optimization/skillopt.py +9 -5
  264. package/src/devcouncil/planning/arbiter_service.py +12 -3
  265. package/src/devcouncil/planning/correction_manifest.py +107 -10
  266. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  267. package/src/devcouncil/planning/plan_service.py +5 -2
  268. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  269. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  270. package/src/devcouncil/planning/question_conversion.py +56 -0
  271. package/src/devcouncil/planning/spec_service.py +9 -3
  272. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  273. package/src/devcouncil/repo/gitignore.py +1 -2
  274. package/src/devcouncil/reporting/evidence_export.py +124 -0
  275. package/src/devcouncil/reporting/evidence_html.py +210 -0
  276. package/src/devcouncil/reporting/json_report.py +16 -12
  277. package/src/devcouncil/reporting/markdown_report.py +38 -9
  278. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  279. package/src/devcouncil/reporting/report_builder.py +40 -4
  280. package/src/devcouncil/reporting/task_provenance.py +42 -0
  281. package/src/devcouncil/reporting/verdict.py +75 -0
  282. package/src/devcouncil/skills/library/README.md +1 -0
  283. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  285. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  286. package/src/devcouncil/skills/registry.py +43 -12
  287. package/src/devcouncil/storage/db.py +57 -11
  288. package/src/devcouncil/storage/models.py +6 -0
  289. package/src/devcouncil/storage/native.py +5 -3
  290. package/src/devcouncil/storage/repositories.py +50 -18
  291. package/src/devcouncil/telemetry/context.py +28 -0
  292. package/src/devcouncil/telemetry/cost.py +4 -5
  293. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  294. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  295. package/src/devcouncil/telemetry/stages.py +27 -2
  296. package/src/devcouncil/telemetry/tracker.py +50 -13
  297. package/src/devcouncil/ui/dashboard.py +120 -8
  298. package/src/devcouncil/utils/fsio.py +58 -0
  299. package/src/devcouncil/utils/git_snapshot.py +112 -0
  300. package/src/devcouncil/utils/json_persist.py +53 -0
  301. package/src/devcouncil/utils/proc.py +89 -0
  302. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  303. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  304. package/src/devcouncil/verification/checks/__init__.py +41 -0
  305. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  306. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  307. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  308. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  309. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  310. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  311. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  312. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  313. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  314. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  315. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  316. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  317. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  318. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  319. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  320. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  321. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  322. package/src/devcouncil/verification/checks/wiring.py +216 -0
  323. package/src/devcouncil/verification/claims/__init__.py +23 -0
  324. package/src/devcouncil/verification/claims/checks.py +395 -0
  325. package/src/devcouncil/verification/claims/mapper.py +168 -0
  326. package/src/devcouncil/verification/claims/models.py +39 -0
  327. package/src/devcouncil/verification/claims/transcript.py +92 -0
  328. package/src/devcouncil/verification/claims/verdict.py +88 -0
  329. package/src/devcouncil/verification/command_evidence.py +170 -0
  330. package/src/devcouncil/verification/command_malformation.py +147 -0
  331. package/src/devcouncil/verification/command_runner.py +164 -0
  332. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  333. package/src/devcouncil/verification/diff_coverage.py +151 -0
  334. package/src/devcouncil/verification/difficulty.py +296 -0
  335. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  336. package/src/devcouncil/verification/gap_ids.py +63 -0
  337. package/src/devcouncil/verification/gate_cache.py +194 -0
  338. package/src/devcouncil/verification/gate_selector.py +344 -0
  339. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  340. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  341. package/src/devcouncil/verification/incremental_check.py +241 -0
  342. package/src/devcouncil/verification/next_actions.py +60 -1
  343. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  344. package/src/devcouncil/verification/sandbox.py +38 -11
  345. package/src/devcouncil/verification/stub_detector.py +369 -0
  346. package/src/devcouncil/verification/test_resolver.py +67 -1
  347. package/src/devcouncil/verification/verifier.py +137 -1666
  348. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  349. package/src/devcouncil/verification/verify_setup.py +176 -0
  350. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  351. package/src/semantic_layer/__init__.py +58 -0
  352. package/src/semantic_layer/benchmark.py +75 -0
  353. package/src/semantic_layer/cache.py +290 -0
  354. package/src/semantic_layer/compressor.py +137 -0
  355. package/src/semantic_layer/config.py +75 -0
  356. package/src/semantic_layer/embeddings.py +69 -0
  357. package/src/semantic_layer/llm_backends.py +99 -0
  358. package/src/semantic_layer/pipeline.py +111 -0
  359. package/src/semantic_layer/router.py +128 -0
  360. package/src/semantic_layer/tuner.py +72 -0
  361. package/uv.lock +973 -9
  362. package/src/devcouncil/artifacts/migrations.py +0 -20
  363. package/src/devcouncil/artifacts/schemas.py +0 -23
  364. package/src/devcouncil/artifacts/serializer.py +0 -21
  365. package/src/devcouncil/integrations/gitnexus.py +0 -70
  366. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -10,16 +10,26 @@ import time
10
10
  import uuid
11
11
  from datetime import datetime, timezone
12
12
  from pathlib import Path
13
- from typing import Optional
13
+ from typing import Any, Callable, Optional
14
14
 
15
15
  from rich.console import Console
16
16
 
17
17
  from devcouncil.domain.requirement import Requirement
18
18
  from devcouncil.domain.task import Task
19
19
  from devcouncil.app.config import DevCouncilConfig, load_config
20
+ from devcouncil.utils.json_persist import read_json, write_json
20
21
  from devcouncil.execution.executor import Executor, ExecutionResult
21
22
  from devcouncil.execution.prompt_builder import PromptBuilder
23
+ from devcouncil.executors.advisor_tool import (
24
+ advisor_steering_text,
25
+ advisor_user_cost_trim,
26
+ claude_supports_append_system_prompt,
27
+ decide_advisor_attach,
28
+ strip_duplicate_advisor_args,
29
+ warn_advisor_preflight,
30
+ )
22
31
  from devcouncil.executors.agent_registry import (
32
+ GEMINI_DEPRECATION_MESSAGE,
23
33
  VALID_INPUT_MODES,
24
34
  CliAgentSpec,
25
35
  get_cli_agent_spec,
@@ -29,6 +39,7 @@ from devcouncil.executors.agent_registry import (
29
39
  )
30
40
  from devcouncil.repo.gitignore import ensure_gitignore
31
41
  from devcouncil.telemetry.traces import TraceLogger
42
+ from devcouncil.telemetry.stages import log_step
32
43
  from devcouncil.telemetry.logging_setup import run_log
33
44
  from devcouncil.utils.redaction import redact_text
34
45
 
@@ -64,11 +75,21 @@ class CodingCliExecutor(Executor):
64
75
  self._config = None
65
76
  self.client = self._normalize_client(client)
66
77
  self.timeout_seconds = timeout_seconds
78
+ if self.client == "gemini":
79
+ logger.warning(GEMINI_DEPRECATION_MESSAGE)
80
+ console.print(f"[yellow]{GEMINI_DEPRECATION_MESSAGE}[/yellow]")
67
81
  self.spec = self._resolve_spec()
68
82
  self.profile_name = profile or self.spec.default_profile or "default"
69
83
  self.profile = load_agent_profiles(project_root).get(self.profile_name)
70
84
  self.last_run_id: str | None = None
71
85
  self.last_transcript_path: Path | None = None
86
+ # Claude Code session id for the most recent run (assigned by DevCouncil via
87
+ # --session-id, or reused via --resume). Lets callers/live-review locate the
88
+ # native ~/.claude transcript for a headless run and resume it for repairs.
89
+ self.last_agent_session_id: str | None = None
90
+ self._pending_claude_session: tuple[Path, str] | None = None
91
+ # True when this run's argv includes ``--advisor`` (Claude only).
92
+ self._advisor_attached: bool = False
72
93
  self.stream_output = self._resolve_stream_output(stream_output)
73
94
 
74
95
  def _normalize_client(self, client: str) -> str:
@@ -95,6 +116,10 @@ class CodingCliExecutor(Executor):
95
116
  base = self._warp_command()
96
117
  elif self.client == "cursor":
97
118
  base = self._cursor_command(task_id)
119
+ elif self.client == "claude":
120
+ base = self._claude_command(task_id)
121
+ elif self.client == "grok":
122
+ base = self._grok_command(task_id)
98
123
  else:
99
124
  base = self.spec.base_command()
100
125
  return self._apply_profile_args(base)
@@ -106,6 +131,7 @@ class CodingCliExecutor(Executor):
106
131
  "codex": "--model",
107
132
  "gemini": "--model",
108
133
  "cursor": "--model",
134
+ "grok": "-m",
109
135
  "qwen": "--model",
110
136
  "opencode": "--model",
111
137
  "aider": "--model",
@@ -125,12 +151,57 @@ class CodingCliExecutor(Executor):
125
151
  result = list(command)
126
152
  result = self._apply_permission_mode(result)
127
153
  result = self._apply_model_override(result)
154
+ result = self._apply_advisor(result)
128
155
  # NOTE: extra_args are NOT appended here. For argument/prompt-file CLIs the prompt
129
156
  # (and sometimes its flag, e.g. warp --prompt / aider --message) is appended last
130
157
  # by _invocation; appending extra_args at the tail here would slot them between the
131
158
  # prompt flag and its value. _invocation places them correctly instead.
132
159
  return result
133
160
 
161
+ def _apply_advisor(self, command: list[str]) -> list[str]:
162
+ """Attach ``--advisor`` for Claude when the profile opts in and pairing looks safe.
163
+
164
+ Soft-skips clear bad main/advisor pairs and non-Anthropic provider envs so Claude
165
+ does not hard-exit and burn the go-loop repair budget. Non-Claude clients ignore
166
+ ``advisor_model``.
167
+ """
168
+ self._advisor_attached = False
169
+ if self.client != "claude" or not self.profile:
170
+ return command
171
+ advisor = (self.profile.advisor_model or "").strip()
172
+ if not advisor:
173
+ return command
174
+ # Prefer profile env overrides, then process env, for provider soft-skip.
175
+ env_for_decision: dict[str, str] = {**os.environ}
176
+ if self.profile.env:
177
+ env_for_decision.update({str(k): str(v) for k, v in self.profile.env.items()})
178
+ decision, resolved, reason = decide_advisor_attach(
179
+ main_model=self.profile.model,
180
+ advisor_model=advisor,
181
+ env=env_for_decision,
182
+ )
183
+ if decision != "attach" or not resolved:
184
+ if reason:
185
+ logger.warning(
186
+ "Skipping --advisor %s for profile %s: %s",
187
+ advisor,
188
+ self.profile_name,
189
+ reason,
190
+ )
191
+ console.print(
192
+ f"[yellow]Skipping --advisor {advisor}: {reason}. "
193
+ "Run continues without the advisor tool.[/yellow]"
194
+ )
195
+ return command
196
+ result = list(command)
197
+ for index, part in enumerate(result):
198
+ if part == "--advisor" and index + 1 < len(result):
199
+ result[index + 1] = resolved
200
+ self._advisor_attached = True
201
+ return result
202
+ self._advisor_attached = True
203
+ return [*result, "--advisor", resolved]
204
+
134
205
  def _apply_model_override(self, command: list[str]) -> list[str]:
135
206
  model = (self.profile.model or "").strip() if self.profile else ""
136
207
  if not model:
@@ -147,10 +218,27 @@ class CodingCliExecutor(Executor):
147
218
 
148
219
  def _apply_permission_mode(self, command: list[str]) -> list[str]:
149
220
  mode = (self.profile.permission_mode or "").strip() if self.profile else ""
221
+ if not mode and self.client == "cursor" and self._config is not None:
222
+ headless_force = self._config.integrations.cursor.headless_force
223
+ if headless_force is True:
224
+ return self._apply_cursor_permission_mode(command, "auto")
225
+ if headless_force is False:
226
+ return command
150
227
  if not mode:
151
228
  return command
229
+ if mode.lower() == "bypasspermissions":
230
+ logger.warning(
231
+ "Profile %r uses bypassPermissions — file edits bypass all CLI gates.",
232
+ self.profile_name,
233
+ )
152
234
  if self.client == "claude":
153
235
  return self._apply_claude_permission_mode(command, mode)
236
+ if self.client == "codex":
237
+ return self._apply_codex_permission_mode(command, mode)
238
+ if self.client == "grok":
239
+ return self._apply_grok_permission_mode(command, mode)
240
+ if self.client == "cursor":
241
+ return self._apply_cursor_permission_mode(command, mode)
154
242
  return command
155
243
 
156
244
  @staticmethod
@@ -174,6 +262,73 @@ class CodingCliExecutor(Executor):
174
262
  return result
175
263
  return [*result, "--permission-mode", value]
176
264
 
265
+ @staticmethod
266
+ def _apply_codex_permission_mode(command: list[str], mode: str) -> list[str]:
267
+ """Translate DevCouncil profiles into current ``codex exec`` flags.
268
+
269
+ ``auto`` remains sandboxed to the workspace, ``plan`` is read-only, and
270
+ only an explicit bypass mode disables both approvals and the sandbox.
271
+ """
272
+ normalized = mode.lower()
273
+ result = list(command)
274
+ dangerous = "--dangerously-bypass-approvals-and-sandbox"
275
+ if normalized in {"bypasspermissions", "danger-full-access", "yolo"}:
276
+ if dangerous not in result:
277
+ result.append(dangerous)
278
+ return result
279
+
280
+ sandbox = {
281
+ "auto": "workspace-write",
282
+ "gated": "workspace-write",
283
+ "ask": "workspace-write",
284
+ "prod": "workspace-write",
285
+ "plan": "read-only",
286
+ "read-only": "read-only",
287
+ "workspace-write": "workspace-write",
288
+ }.get(normalized)
289
+ if sandbox is None:
290
+ return result
291
+ for index, part in enumerate(result):
292
+ if part in {"--sandbox", "-s"} and index + 1 < len(result):
293
+ result[index + 1] = sandbox
294
+ return result
295
+ return [*result, "--sandbox", sandbox]
296
+
297
+ @staticmethod
298
+ def _apply_grok_permission_mode(command: list[str], mode: str) -> list[str]:
299
+ """Translate an abstract permission mode into Grok Build's
300
+ ``--permission-mode`` value."""
301
+ translation = {
302
+ "auto": "acceptEdits",
303
+ "yolo": "acceptEdits",
304
+ "gated": "dontAsk",
305
+ "prod": "dontAsk",
306
+ "ask": "default",
307
+ "plan": "plan",
308
+ }
309
+ value = translation.get(mode.lower(), mode)
310
+ result = list(command)
311
+ for index, part in enumerate(result):
312
+ if part == "--permission-mode" and index + 1 < len(result):
313
+ result[index + 1] = value
314
+ return result
315
+ return [*result, "--permission-mode", value]
316
+
317
+ def _apply_cursor_permission_mode(self, command: list[str], mode: str) -> list[str]:
318
+ """Map profile permission modes to Cursor headless flags."""
319
+ normalized = mode.lower()
320
+ result = list(command)
321
+ if normalized in {"auto", "yolo"}:
322
+ if "--force" not in result and "--yolo" not in result:
323
+ result.insert(1, "--force")
324
+ return result
325
+ if normalized == "plan":
326
+ if "--mode=plan" not in result:
327
+ result.insert(1, "--mode=plan")
328
+ return result
329
+ # gated/prod: omit force; trust + verify loop contain edits.
330
+ return result
331
+
177
332
  def _cursor_command(self, task_id: str | None = None) -> list[str]:
178
333
  executable = resolve_cursor_agent_executable()
179
334
  if not executable:
@@ -185,12 +340,33 @@ class CodingCliExecutor(Executor):
185
340
  "--workspace",
186
341
  str(self.project_root),
187
342
  ]
343
+ if self.stream_output:
344
+ command.extend(["--output-format", "stream-json", "--stream-partial-output"])
345
+ else:
346
+ command.extend(["--output-format", "json"])
188
347
  chat_id = self._cursor_resume_chat_id(task_id)
189
348
  if chat_id:
190
349
  command.extend(["--resume", chat_id])
191
350
  command.append("Read and execute the DevCouncil task prompt at {prompt_file}.")
192
351
  return command
193
352
 
353
+ def _grok_command(self, task_id: str | None = None) -> list[str]:
354
+ command = [
355
+ "grok",
356
+ "-p",
357
+ "Read and execute the DevCouncil task prompt at {prompt_file}.",
358
+ "--directory",
359
+ str(self.project_root),
360
+ ]
361
+ if self.stream_output:
362
+ command.extend(["--output-format", "stream-json"])
363
+ else:
364
+ command.extend(["--output-format", "json"])
365
+ session_id = self._grok_resume_session_id(task_id)
366
+ if session_id:
367
+ command.extend(["--resume", session_id])
368
+ return command
369
+
194
370
  def _warp_command(self) -> list[str]:
195
371
  config = self._load_warp_config()
196
372
  command = config.get("command", "oz")
@@ -233,6 +409,12 @@ class CodingCliExecutor(Executor):
233
409
 
234
410
  def run_task(self, task: Task, requirements: list[Requirement]) -> ExecutionResult:
235
411
  logger.info("coding_cli.run_task: client=%s profile=%s task=%s", self.client, self.profile_name, task.id)
412
+ log_step(
413
+ f"executor/{self.client}: starting task {task.id}",
414
+ project_root=self.project_root,
415
+ task_id=task.id,
416
+ profile=self.profile_name,
417
+ )
236
418
  if self.profile is None:
237
419
  logger.error("Unknown agent profile %r for %s; cannot start.", self.profile_name, self.client)
238
420
  return ExecutionResult(
@@ -266,23 +448,27 @@ class CodingCliExecutor(Executor):
266
448
  )
267
449
 
268
450
  prompt = PromptBuilder(self.project_root).build_task_prompt(task, requirements)
269
- from devcouncil.planning.correction_manifest import load_latest_correction_manifest
270
-
271
- correction = load_latest_correction_manifest(self.project_root, task.id)
272
- if correction is not None:
273
- prompt = (
274
- f"# DevCouncil Correction Manifest\n\n"
275
- f"{correction.model_dump_json(indent=2)}\n\n"
276
- f"{prompt}"
277
- )
451
+ from devcouncil.planning.correction_manifest import repair_prompt_prefix
452
+
453
+ prefix = repair_prompt_prefix(self.project_root, task.id)
454
+ if prefix:
455
+ prompt = f"{prefix}{prompt}"
278
456
  prompt = self._apply_profile_prompt(prompt)
457
+ # Claude-only advisor steering (not PromptBuilder — shared by all executors).
458
+ prompt, advisor_system = self._apply_advisor_steering(prompt, repair=bool(prefix))
279
459
  instruction_file = self.project_root / ".devcouncil" / f"{task.id}-{self.client}-task.md"
280
460
  instruction_file.parent.mkdir(parents=True, exist_ok=True)
281
461
  instruction_file.write_text(prompt, encoding="utf-8")
282
462
 
283
- custom_env = self.spec.env
284
- env = {**dict(os.environ), **custom_env, "DEVCOUNCIL_PROJECT_ROOT": str(self.project_root)}
285
- env["DEVCOUNCIL_AGENT_PROFILE"] = self.profile_name
463
+ env = self._build_env()
464
+ if self.client == "claude" and self._advisor_attached:
465
+ for warning in warn_advisor_preflight(
466
+ env=env,
467
+ main_model=self.profile.model if self.profile else None,
468
+ advisor_model=self.profile.advisor_model if self.profile else None,
469
+ ):
470
+ logger.warning("%s", warning)
471
+ console.print(f"[yellow]{warning}[/yellow]")
286
472
  log_prefix = f"{task.id}-{self.client}"
287
473
  run_id = str(uuid.uuid4())
288
474
  self.last_run_id = run_id
@@ -299,6 +485,8 @@ class CodingCliExecutor(Executor):
299
485
  started = time.monotonic()
300
486
  try:
301
487
  invocation, input_text = self._invocation(command, prompt, instruction_file)
488
+ if advisor_system:
489
+ invocation = self._inject_append_system_prompt(invocation, advisor_system, prompt)
302
490
  display_invocation = self._display_invocation(invocation, prompt)
303
491
  # Print the resolved command (placeholders like {prompt_file} already
304
492
  # substituted, prompt redacted) rather than the raw template.
@@ -310,6 +498,12 @@ class CodingCliExecutor(Executor):
310
498
  instruction_file,
311
499
  stream=self.stream_output,
312
500
  )
501
+ # Persist the Claude session id now (before the subprocess), so a crashed or
502
+ # timed-out run still leaves a resumable session, and record it on the manifest.
503
+ if self.client == "claude":
504
+ self._persist_claude_session()
505
+ if self.last_agent_session_id:
506
+ self._update_run_manifest(run_id, agent_session_id=self.last_agent_session_id)
313
507
  TraceLogger(self.project_root).log_event(
314
508
  "agent_run_started",
315
509
  {
@@ -330,11 +524,72 @@ class CodingCliExecutor(Executor):
330
524
  )
331
525
  started = time.monotonic()
332
526
  logger.info("Launching %s subprocess for %s (timeout=%ss)", self.client, task.id, self._effective_timeout())
333
- result = self._run_subprocess(invocation, input_text, env, transcript_path=transcript_path)
527
+ # Render Claude's NDJSON stream as readable lines while it runs (raw is still
528
+ # captured to the transcript); other clients print their output verbatim.
529
+ display_transform = (
530
+ self._render_claude_stream_event
531
+ if (self.client == "claude" and self.stream_output)
532
+ else None
533
+ )
534
+ result = self._run_subprocess(
535
+ invocation, input_text, env,
536
+ transcript_path=transcript_path,
537
+ display_transform=display_transform,
538
+ )
539
+ # Transient-failure retry: a CLI that died on a network/provider fault
540
+ # ("API Error: Connection closed mid-response", 429/5xx, overloaded) says
541
+ # nothing about the task — without a retry the failure ends the task
542
+ # `blocked`, burns a repair attempt on a non-code problem, and surfaces
543
+ # as a false negative (seen directly in the benchmark). Only failures
544
+ # whose output matches a known-transient signature are retried, with a
545
+ # short backoff; genuine agent errors still fail immediately.
546
+ retries = 0
547
+ retry_limit = self._transient_retry_limit()
548
+ while result.returncode != 0 and retries < retry_limit:
549
+ reason = self._transient_failure_reason(result)
550
+ if reason is None:
551
+ break
552
+ retries += 1
553
+ delay = min(30.0, 5.0 * retries)
554
+ logger.warning(
555
+ "%s failed with a transient error for %s (%s); retrying %d/%d in %.0fs",
556
+ self.client, task.id, reason, retries, retry_limit, delay,
557
+ )
558
+ TraceLogger(self.project_root).log_event(
559
+ "agent_run_transient_retry",
560
+ {
561
+ "agent": self.client,
562
+ "profile": self.profile_name,
563
+ "returncode": result.returncode,
564
+ "reason": reason,
565
+ "attempt": retries,
566
+ "limit": retry_limit,
567
+ },
568
+ run_id=run_id,
569
+ task_id=task.id,
570
+ summary=f"Transient {self.client} failure ({reason}); retry {retries}/{retry_limit}",
571
+ )
572
+ time.sleep(delay)
573
+ result = self._run_subprocess(
574
+ invocation, input_text, env,
575
+ transcript_path=transcript_path,
576
+ display_transform=display_transform,
577
+ )
334
578
  duration = round(time.monotonic() - started, 3)
335
579
  logger.info("%s subprocess for %s exited %s in %.2fs", self.client, task.id, result.returncode, duration)
336
580
  finished_at = datetime.now(timezone.utc).isoformat()
337
581
  self._write_log(log_prefix, result)
582
+ # Capture Claude's structured result onto the manifest. Non-stream: parse the one
583
+ # JSON blob and swap stdout for the human ``result`` text so previews stay readable.
584
+ # Stream: harvest the terminal result event from the captured NDJSON for telemetry.
585
+ if self.client == "claude":
586
+ if self.stream_output:
587
+ self._capture_claude_stream_json(run_id, result)
588
+ else:
589
+ result = self._capture_claude_json(run_id, result)
590
+ self._mirror_claude_transcript()
591
+ if self.client == "grok":
592
+ self._capture_grok_session_from_result(task.id, result)
338
593
  if transcript_path and transcript_path.exists():
339
594
  self._append_manifest_transcript(run_id, transcript_path)
340
595
  self.last_transcript_path = transcript_path
@@ -379,6 +634,14 @@ class CodingCliExecutor(Executor):
379
634
  task_id=task.id,
380
635
  summary=f"{self.client} finished for {task.id}",
381
636
  )
637
+ log_step(
638
+ f"executor/{self.client}: finished task {task.id}",
639
+ project_root=self.project_root,
640
+ task_id=task.id,
641
+ returncode=result.returncode,
642
+ duration_s=round(duration, 2),
643
+ trace=True,
644
+ )
382
645
  # Opt-in pre-verify scope gate: this CLI subprocess wrote directly to disk with
383
646
  # no per-write hook, so revert any out-of-scope change now (before it reaches the
384
647
  # verify gate or a commit) rather than only flagging it as orphan_diff post-verify.
@@ -435,6 +698,26 @@ class CodingCliExecutor(Executor):
435
698
  finally:
436
699
  run_log_cm.__exit__(None, None, None)
437
700
 
701
+ def _build_env(self) -> dict[str, str]:
702
+ """Environment for the agent subprocess.
703
+
704
+ Layering (later wins): parent process env → the agent spec's ``env`` (custom
705
+ agents) → the profile's ``env`` overrides → DevCouncil's own variables. The
706
+ profile layer is what lets a profile redirect the Claude Code harness at an
707
+ alternative Anthropic-compatible endpoint (``ANTHROPIC_BASE_URL`` /
708
+ ``ANTHROPIC_AUTH_TOKEN`` / ``ANTHROPIC_MODEL``, e.g. a local LiteLLM proxy in
709
+ front of Ollama or OpenRouter) — the same provider-redirection trick DevPrism's
710
+ loopback proxy uses — without editing the base spec. DevCouncil's variables are
711
+ applied last so no profile can mask them."""
712
+ profile_env = dict(self.profile.env) if (self.profile and self.profile.env) else {}
713
+ return {
714
+ **dict(os.environ),
715
+ **self.spec.env,
716
+ **profile_env,
717
+ "DEVCOUNCIL_PROJECT_ROOT": str(self.project_root),
718
+ "DEVCOUNCIL_AGENT_PROFILE": self.profile_name,
719
+ }
720
+
438
721
  def _scope_enforcement_enabled(self) -> bool:
439
722
  try:
440
723
  from devcouncil.app.config import load_config
@@ -442,6 +725,58 @@ class CodingCliExecutor(Executor):
442
725
  except Exception:
443
726
  return False
444
727
 
728
+ # Output signatures of failures caused by the NETWORK/PROVIDER, not the task.
729
+ # Matched case-insensitively against stderr plus the tail of stdout. Deliberately
730
+ # phrase-based (no bare status-code numbers) so code/diff content in stdout cannot
731
+ # spuriously classify a genuine agent failure as transient.
732
+ _TRANSIENT_FAILURE_MARKERS = (
733
+ "connection closed",
734
+ "connection reset",
735
+ "connection refused",
736
+ "connection error",
737
+ "connection aborted",
738
+ "econnreset",
739
+ "econnrefused",
740
+ "etimedout",
741
+ "socket hang up",
742
+ "mid-response",
743
+ "network error",
744
+ "fetch failed",
745
+ "temporarily unavailable",
746
+ "service unavailable",
747
+ "internal server error",
748
+ "bad gateway",
749
+ "gateway timeout",
750
+ "overloaded",
751
+ "rate limit",
752
+ "too many requests",
753
+ "request timed out",
754
+ "timeout awaiting",
755
+ "tls handshake",
756
+ "dns",
757
+ )
758
+
759
+ def _transient_failure_reason(self, result: "subprocess.CompletedProcess[str]") -> str | None:
760
+ """The matched transient marker when a failed run looks network/provider-caused,
761
+ else None. Scans stderr fully and only the TAIL of stdout (where CLIs print
762
+ their final error) so large code output cannot trigger a false match."""
763
+ haystack = f"{result.stderr or ''}\n{(result.stdout or '')[-4000:]}".lower()
764
+ for marker in self._TRANSIENT_FAILURE_MARKERS:
765
+ if marker in haystack:
766
+ return marker
767
+ return None
768
+
769
+ def _transient_retry_limit(self) -> int:
770
+ """Max transient-failure retries from ``execution.transient_retry_attempts``.
771
+
772
+ Defaults conservatively (2) when config is unavailable; 0 disables retry."""
773
+ try:
774
+ from devcouncil.app.config import load_config
775
+
776
+ return max(0, int(load_config(self.project_root).execution.transient_retry_attempts))
777
+ except Exception:
778
+ return 2
779
+
445
780
  def _enforce_file_scope(self, task: Task) -> list[tuple[str, str]]:
446
781
  """Revert any file this task's subprocess changed that the task does not authorize.
447
782
 
@@ -453,8 +788,14 @@ class CodingCliExecutor(Executor):
453
788
  from devcouncil.verification.verifier import Verifier
454
789
 
455
790
  changed = Verifier(self.project_root).get_task_changed_files(task.id)
456
- except Exception:
457
- return []
791
+ except Exception as exc:
792
+ logger.exception(
793
+ "Scope enforcement failed for %s: could not load changed files",
794
+ task.id,
795
+ )
796
+ raise RuntimeError(
797
+ f"Scope enforcement failed for {task.id}: could not determine changed files ({exc})."
798
+ ) from exc
458
799
  engine = TaskPolicyEngine(self.project_root)
459
800
  reverted: list[tuple[str, str]] = []
460
801
  for path in changed:
@@ -465,31 +806,43 @@ class CodingCliExecutor(Executor):
465
806
  try:
466
807
  decision = engine.evaluate_file_change(path, task, "write")
467
808
  except Exception:
809
+ logger.warning(
810
+ "Scope enforcement skipped policy check for %s on %s",
811
+ task.id,
812
+ path,
813
+ exc_info=True,
814
+ )
468
815
  continue
469
816
  if decision.action == "deny" and self._revert_path(path):
470
817
  reverted.append((path, decision.reason))
471
818
  return reverted
472
819
 
820
+ _GIT_REVERT_TIMEOUT_SECONDS = 120
821
+
473
822
  def _revert_path(self, rel_path: str) -> bool:
474
823
  """Undo an out-of-scope change. A file that exists in HEAD is restored to HEAD; a
475
824
  file the task newly added (absent from HEAD, or no HEAD at all) is unstaged and
476
825
  deleted. Best-effort — a failed revert returns False so the path is not reported as
477
826
  cleanly gated (and the caller does not claim it was reverted)."""
827
+ timeout = self._GIT_REVERT_TIMEOUT_SECONDS
478
828
  try:
479
829
  in_head = subprocess.run(
480
830
  ["git", "cat-file", "-e", f"HEAD:{rel_path}"],
481
831
  cwd=self.project_root, capture_output=True, text=True,
832
+ timeout=timeout,
482
833
  ).returncode == 0
483
834
  if in_head:
484
835
  return subprocess.run(
485
836
  ["git", "checkout", "HEAD", "--", rel_path],
486
837
  cwd=self.project_root, capture_output=True, text=True,
838
+ timeout=timeout,
487
839
  ).returncode == 0
488
840
  # New file (incl. the no-HEAD case): unstage if staged, then remove the working
489
841
  # copy so it cannot be committed by the next repair attempt.
490
842
  subprocess.run(
491
843
  ["git", "rm", "-f", "--cached", "--ignore-unmatch", rel_path],
492
844
  cwd=self.project_root, capture_output=True, text=True,
845
+ timeout=timeout,
493
846
  )
494
847
  full = self.project_root / rel_path
495
848
  if full.is_file():
@@ -541,6 +894,7 @@ class CodingCliExecutor(Executor):
541
894
  input_text: str | None,
542
895
  env: dict[str, str],
543
896
  transcript_path: Path | None = None,
897
+ display_transform: Callable[[str], str | None] | None = None,
544
898
  ) -> subprocess.CompletedProcess[str]:
545
899
  timeout = self._effective_timeout()
546
900
  invocation = self._resolve_invocation(invocation, env)
@@ -625,7 +979,17 @@ class CodingCliExecutor(Executor):
625
979
  continue
626
980
  if line is None:
627
981
  break
628
- self._emit_stream_line(line)
982
+ # Display may be transformed (e.g. Claude NDJSON → a readable line, or
983
+ # suppressed for noise), but capture and transcript always keep the raw
984
+ # bytes so post-run parsing and forensics see the full stream.
985
+ display: str | None = line
986
+ if display_transform is not None:
987
+ try:
988
+ display = display_transform(line)
989
+ except Exception:
990
+ display = line
991
+ if display:
992
+ self._emit_stream_line(display)
629
993
  captured.append(line)
630
994
  if transcript_handle is not None:
631
995
  transcript_handle.write(redact_text(line))
@@ -655,6 +1019,18 @@ class CodingCliExecutor(Executor):
655
1019
  return "off"
656
1020
  return mode
657
1021
 
1022
+ def _grok_resume_mode(self) -> str:
1023
+ try:
1024
+ if self._config is None:
1025
+ mode = "off"
1026
+ else:
1027
+ mode = (self._config.execution.grok_resume_mode or "off").strip().lower()
1028
+ except Exception:
1029
+ mode = "off"
1030
+ if mode not in {"off", "project", "task"}:
1031
+ return "off"
1032
+ return mode
1033
+
658
1034
  def _cursor_session_path(self, task_id: str | None = None) -> Path:
659
1035
  if self._cursor_resume_mode() == "task" and task_id:
660
1036
  return self.project_root / ".devcouncil" / "sessions" / f"{task_id}-cursor.json"
@@ -667,7 +1043,7 @@ class CodingCliExecutor(Executor):
667
1043
  path = self._cursor_session_path(task_id if mode == "task" else None)
668
1044
  if path.exists():
669
1045
  try:
670
- data = json.loads(path.read_text(encoding="utf-8")) or {}
1046
+ data = read_json(path) or {}
671
1047
  except json.JSONDecodeError:
672
1048
  data = {}
673
1049
  existing_chat_id = str(data.get("chat_id") or "").strip()
@@ -677,7 +1053,7 @@ class CodingCliExecutor(Executor):
677
1053
  if not ensured_chat_id:
678
1054
  return None
679
1055
  path.parent.mkdir(parents=True, exist_ok=True)
680
- path.write_text(json.dumps({"chat_id": ensured_chat_id}, indent=2) + "\n", encoding="utf-8")
1056
+ write_json(path, {"chat_id": ensured_chat_id})
681
1057
  return ensured_chat_id
682
1058
 
683
1059
  def _ensure_cursor_chat_id(self) -> str | None:
@@ -701,6 +1077,292 @@ class CodingCliExecutor(Executor):
701
1077
  chat_id = (result.stdout or result.stderr or "").strip().splitlines()[-1].strip()
702
1078
  return chat_id or None
703
1079
 
1080
+ def _grok_session_path(self, task_id: str | None = None) -> Path:
1081
+ if self._grok_resume_mode() == "task" and task_id:
1082
+ return self.project_root / ".devcouncil" / "sessions" / f"{task_id}-grok.json"
1083
+ return self.project_root / ".devcouncil" / "integrations" / "grok-session.json"
1084
+
1085
+ def _grok_resume_session_id(self, task_id: str | None) -> str | None:
1086
+ mode = self._grok_resume_mode()
1087
+ if mode == "off":
1088
+ return None
1089
+ path = self._grok_session_path(task_id if mode == "task" else None)
1090
+ if path.exists():
1091
+ try:
1092
+ data = read_json(path) or {}
1093
+ except json.JSONDecodeError:
1094
+ data = {}
1095
+ existing = str(data.get("session_id") or "").strip()
1096
+ if existing:
1097
+ return existing
1098
+ return None
1099
+
1100
+ def _persist_grok_session(self, session_id: str, task_id: str | None) -> None:
1101
+ mode = self._grok_resume_mode()
1102
+ if mode == "off" or not session_id:
1103
+ return
1104
+ path = self._grok_session_path(task_id if mode == "task" else None)
1105
+ try:
1106
+ path.parent.mkdir(parents=True, exist_ok=True)
1107
+ write_json(path, {"session_id": session_id})
1108
+ except OSError:
1109
+ pass
1110
+
1111
+ def _capture_grok_session_from_result(
1112
+ self, task_id: str, result: subprocess.CompletedProcess[str]
1113
+ ) -> None:
1114
+ """Best-effort harvest of Grok session id from JSON output for resume."""
1115
+ if self._grok_resume_mode() == "off":
1116
+ return
1117
+ raw = (result.stdout or "").strip()
1118
+ if not raw:
1119
+ return
1120
+ payload: dict | None = None
1121
+ for line in reversed(raw.splitlines()):
1122
+ line = line.strip()
1123
+ if not line:
1124
+ continue
1125
+ try:
1126
+ candidate = json.loads(line)
1127
+ except json.JSONDecodeError:
1128
+ continue
1129
+ if isinstance(candidate, dict):
1130
+ payload = candidate
1131
+ break
1132
+ if payload is None:
1133
+ try:
1134
+ loaded = json.loads(raw)
1135
+ except json.JSONDecodeError:
1136
+ return
1137
+ payload = loaded if isinstance(loaded, dict) else None
1138
+ if payload is None:
1139
+ return
1140
+ session_id = str(payload.get("session_id") or payload.get("sessionId") or "").strip()
1141
+ if session_id:
1142
+ self.last_agent_session_id = session_id
1143
+ self._persist_grok_session(session_id, task_id)
1144
+
1145
+ def _claude_command(self, task_id: str | None = None) -> list[str]:
1146
+ """Build Claude Code's headless command with a stable session identity.
1147
+
1148
+ A fresh task run is pinned to a new UUID via ``--session-id`` so DevCouncil
1149
+ knows, up front, which native ``~/.claude`` transcript the run will write —
1150
+ making headless evidence and live review locatable without scraping stdout.
1151
+ A re-run of the SAME task (e.g. a repair iteration) instead ``--resume``\\s the
1152
+ prior session so the model keeps the context of what it already tried, rather
1153
+ than restarting cold from a re-prepended correction manifest. The chosen id is
1154
+ stashed in ``_pending_claude_session`` and persisted once the run launches."""
1155
+ base = list(self.spec.base_command())
1156
+ # Capture Claude's structured result either way. Non-stream: one JSON blob parsed
1157
+ # after exit. Stream: NDJSON events rendered live (readable lines, not raw JSON) and
1158
+ # the terminal result event harvested for telemetry. ``--verbose`` is required by
1159
+ # Claude for stream-json.
1160
+ if self.stream_output:
1161
+ base = [*base, "--output-format", "stream-json", "--verbose"]
1162
+ else:
1163
+ base = [*base, "--output-format", "json"]
1164
+ session_path = self._claude_session_path(task_id)
1165
+ prior = self._read_claude_session_id(session_path)
1166
+ if prior:
1167
+ self._pending_claude_session = (session_path, prior)
1168
+ return [*base, "--resume", prior]
1169
+ new_id = str(uuid.uuid4())
1170
+ self._pending_claude_session = (session_path, new_id)
1171
+ return [*base, "--session-id", new_id]
1172
+
1173
+ def _claude_session_path(self, task_id: str | None) -> Path:
1174
+ name = f"{task_id}-claude.json" if task_id else "claude-session.json"
1175
+ return self.project_root / ".devcouncil" / "sessions" / name
1176
+
1177
+ @staticmethod
1178
+ def _read_claude_session_id(path: Path) -> str | None:
1179
+ if not path.exists():
1180
+ return None
1181
+ try:
1182
+ data = read_json(path) or {}
1183
+ except json.JSONDecodeError:
1184
+ return None
1185
+ session_id = str(data.get("session_id") or "").strip()
1186
+ return session_id or None
1187
+
1188
+ def _persist_claude_session(self) -> None:
1189
+ """Record the run's Claude session id so a later re-run of the task resumes it.
1190
+
1191
+ Persisted after the subprocess launches (not on exit) so even a crashed or
1192
+ timed-out run leaves a resumable session behind. Also exposes the id via
1193
+ ``last_agent_session_id`` for the caller and the run manifest."""
1194
+ pending = self._pending_claude_session
1195
+ if not pending:
1196
+ return
1197
+ path, session_id = pending
1198
+ self.last_agent_session_id = session_id
1199
+ try:
1200
+ path.parent.mkdir(parents=True, exist_ok=True)
1201
+ write_json(path, {"session_id": session_id})
1202
+ except OSError:
1203
+ # A non-persisted session only costs a cold repair next time; never fail the run.
1204
+ pass
1205
+
1206
+ def _mirror_claude_transcript(self) -> None:
1207
+ """Best-effort copy of Claude's native JSONL into the project for live review."""
1208
+ session_id = self.last_agent_session_id
1209
+ if not session_id:
1210
+ return
1211
+ try:
1212
+ from devcouncil.live.transcripts import mirror_claude_transcript
1213
+
1214
+ mirror_claude_transcript(self.project_root, session_id)
1215
+ except Exception as exc:
1216
+ logger.debug("Claude transcript mirror failed: %s", exc)
1217
+
1218
+ def _capture_claude_json(
1219
+ self, run_id: str, result: subprocess.CompletedProcess[str]
1220
+ ) -> subprocess.CompletedProcess[str]:
1221
+ """Parse Claude Code's ``--output-format json`` result blob.
1222
+
1223
+ Records the reported session id, cost, and token usage on the run manifest, then
1224
+ returns a copy of ``result`` with stdout replaced by the human-readable ``result``
1225
+ text so downstream logs/previews/diagnostics read like the old text mode. On any
1226
+ parse failure the original ``result`` is returned unchanged — a telemetry miss must
1227
+ never turn a successful run into a reported failure."""
1228
+ raw = (result.stdout or "").strip()
1229
+ if not raw:
1230
+ return result
1231
+ payload: dict | None = None
1232
+ # Tolerate any leading noise: scan lines from the last back for a JSON object.
1233
+ for line in reversed(raw.splitlines()):
1234
+ line = line.strip()
1235
+ if not line:
1236
+ continue
1237
+ try:
1238
+ candidate = json.loads(line)
1239
+ except json.JSONDecodeError:
1240
+ continue
1241
+ if isinstance(candidate, dict):
1242
+ payload = candidate
1243
+ break
1244
+ if payload is None:
1245
+ try:
1246
+ loaded = json.loads(raw)
1247
+ except json.JSONDecodeError:
1248
+ return result
1249
+ payload = loaded if isinstance(loaded, dict) else None
1250
+ if payload is None:
1251
+ return result
1252
+
1253
+ self._record_claude_result_meta(run_id, payload)
1254
+ text = payload.get("result")
1255
+ if isinstance(text, str) and text.strip():
1256
+ return subprocess.CompletedProcess(
1257
+ result.args, result.returncode, stdout=text, stderr=result.stderr
1258
+ )
1259
+ return result
1260
+
1261
+ def _record_claude_result_meta(self, run_id: str, payload: dict) -> None:
1262
+ """Record session id, cost, and token usage from a Claude ``result`` payload.
1263
+
1264
+ Shared by the non-stream JSON path and the stream-json path; both surface the same
1265
+ terminal ``result`` object, just delivered as one blob vs. the last NDJSON line."""
1266
+ raw_usage = payload.get("usage")
1267
+ usage: dict[Any, Any] = raw_usage if isinstance(raw_usage, dict) else {}
1268
+ session_id = str(payload.get("session_id") or "").strip() or None
1269
+ if session_id:
1270
+ self.last_agent_session_id = session_id
1271
+ self._update_run_manifest(run_id, agent_session_id=session_id)
1272
+ # Keep the persisted resume pointer authoritative: if Claude reported a session
1273
+ # id different from the one we assigned, a later --resume must target the real
1274
+ # one, so rewrite the session file to what actually ran.
1275
+ pending = self._pending_claude_session
1276
+ if pending is not None and pending[1] != session_id:
1277
+ self._pending_claude_session = (pending[0], session_id)
1278
+ self._persist_claude_session()
1279
+ meta = {
1280
+ "session_id": session_id,
1281
+ "total_cost_usd": payload.get("total_cost_usd"),
1282
+ "num_turns": payload.get("num_turns"),
1283
+ "is_error": payload.get("is_error"),
1284
+ "input_tokens": usage.get("input_tokens"),
1285
+ "output_tokens": usage.get("output_tokens"),
1286
+ "cache_read_input_tokens": usage.get("cache_read_input_tokens"),
1287
+ }
1288
+ self._update_run_manifest(run_id, agent_result={k: v for k, v in meta.items() if v is not None})
1289
+
1290
+ def _capture_claude_stream_json(self, run_id: str, result: subprocess.CompletedProcess[str]) -> None:
1291
+ """Record telemetry from a streamed NDJSON run by finding the terminal result event.
1292
+
1293
+ The streaming path already rendered readable lines live; here we only harvest the
1294
+ last ``result`` event for the manifest. Best-effort — never raises into the run."""
1295
+ payload: dict | None = None
1296
+ for line in (result.stdout or "").splitlines():
1297
+ line = line.strip()
1298
+ if not line:
1299
+ continue
1300
+ try:
1301
+ event = json.loads(line)
1302
+ except json.JSONDecodeError:
1303
+ continue
1304
+ if isinstance(event, dict) and event.get("type") == "result":
1305
+ payload = event # keep scanning; the last result event wins
1306
+ if payload is not None:
1307
+ self._record_claude_result_meta(run_id, payload)
1308
+
1309
+ @staticmethod
1310
+ def _render_claude_stream_event(line: str) -> str | None:
1311
+ """Turn one Claude stream-json NDJSON line into a concise console line.
1312
+
1313
+ Returns the text to display, or None to suppress the event (system/thinking/rate-
1314
+ limit noise). Non-JSON lines pass through verbatim so nothing is silently dropped."""
1315
+ stripped = line.strip()
1316
+ if not stripped:
1317
+ return None
1318
+ try:
1319
+ event = json.loads(stripped)
1320
+ except json.JSONDecodeError:
1321
+ return line
1322
+ if not isinstance(event, dict):
1323
+ return line
1324
+ etype = event.get("type")
1325
+ if etype == "assistant":
1326
+ raw_message = event.get("message")
1327
+ message: dict[Any, Any] = raw_message if isinstance(raw_message, dict) else {}
1328
+ parts: list[str] = []
1329
+ for block in (message.get("content") or []):
1330
+ if not isinstance(block, dict):
1331
+ continue
1332
+ btype = block.get("type")
1333
+ if btype == "text" and str(block.get("text") or "").strip():
1334
+ parts.append(str(block["text"]).strip())
1335
+ elif btype in {"tool_use", "server_tool_use"}:
1336
+ name = str(block.get("name") or "tool")
1337
+ if name == "advisor" or (
1338
+ btype == "server_tool_use" and "advisor" in name.lower()
1339
+ ):
1340
+ raw_input = block.get("input")
1341
+ tool_input: dict[Any, Any] = raw_input if isinstance(raw_input, dict) else {}
1342
+ model = str(
1343
+ tool_input.get("model")
1344
+ or block.get("model")
1345
+ or ""
1346
+ ).strip()
1347
+ parts.append(f"Advising{f' ({model})' if model else ''}")
1348
+ continue
1349
+ raw_input = block.get("input")
1350
+ tool_input = raw_input if isinstance(raw_input, dict) else {}
1351
+ target = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("command") or ""
1352
+ parts.append(f"→ {name} {str(target)[:80]}".rstrip())
1353
+ text = "\n".join(p for p in parts if p)
1354
+ return text + "\n" if text else None
1355
+ if etype == "result":
1356
+ bits: list[str] = []
1357
+ if event.get("num_turns") is not None:
1358
+ bits.append(f"{event['num_turns']} turns")
1359
+ cost = event.get("total_cost_usd")
1360
+ if isinstance(cost, (int, float)):
1361
+ bits.append(f"${cost:.4f}")
1362
+ return ("✓ " + ", ".join(bits) + "\n") if bits else None
1363
+ # system / user / tool_result / rate_limit_event: keep the console quiet.
1364
+ return None
1365
+
704
1366
  def _effective_timeout(self) -> int:
705
1367
  if self.profile and self.profile.timeout_seconds:
706
1368
  return int(self.profile.timeout_seconds)
@@ -713,6 +1375,9 @@ class CodingCliExecutor(Executor):
713
1375
  for part in command
714
1376
  ]
715
1377
  extra = list(self.profile.extra_args) if (self.profile and self.profile.extra_args) else []
1378
+ # Avoid a second --advisor when DevCouncil already attached one from advisor_model.
1379
+ if self._advisor_attached and extra:
1380
+ extra = strip_duplicate_advisor_args(extra)
716
1381
 
717
1382
  def _place(base: list[str]) -> list[str]:
718
1383
  """Insert profile extra_args after the base flags but before a trailing prompt
@@ -743,6 +1408,30 @@ class CodingCliExecutor(Executor):
743
1408
  def _display_invocation(self, invocation: list[str], prompt: str) -> list[str]:
744
1409
  return [part.replace(prompt, "<task prompt>") for part in invocation]
745
1410
 
1411
+ @staticmethod
1412
+ def _inject_append_system_prompt(
1413
+ invocation: list[str], system_prompt: str, user_prompt: str
1414
+ ) -> list[str]:
1415
+ """Insert ``--append-system-prompt`` without splitting a trailing prompt value."""
1416
+ if invocation and invocation[-1] == user_prompt:
1417
+ return [*invocation[:-1], "--append-system-prompt", system_prompt, user_prompt]
1418
+ return [*invocation, "--append-system-prompt", system_prompt]
1419
+
1420
+ def _apply_advisor_steering(self, prompt: str, *, repair: bool) -> tuple[str, str | None]:
1421
+ """Claude-only advisor steering. Prefer ``--append-system-prompt``; else prompt prefix.
1422
+
1423
+ Injects steering **only** when ``--advisor`` actually attached. Soft-skipped and
1424
+ non-Claude clients get no nudge. Returns ``(prompt, append_system_or_none)``.
1425
+ """
1426
+ if self.client != "claude" or not self.profile or not self._advisor_attached:
1427
+ return prompt, None
1428
+ steer = advisor_steering_text(repair=repair)
1429
+ cost_trim = advisor_user_cost_trim()
1430
+ # Prefer system append when the flag is available; keep soft cost trim in user prompt.
1431
+ if claude_supports_append_system_prompt():
1432
+ return f"{cost_trim}\n\n{prompt}", steer
1433
+ return f"# DevCouncil Advisor\n{steer}\n\n{cost_trim}\n\n{prompt}", None
1434
+
746
1435
  def _apply_profile_prompt(self, prompt: str) -> str:
747
1436
  if not self.profile:
748
1437
  return prompt
@@ -759,11 +1448,21 @@ class CodingCliExecutor(Executor):
759
1448
  """Resolved per-profile CLI overrides recorded in the manifest so a
760
1449
  supervisor can see exactly how the profile constrained the invocation."""
761
1450
  if not self.profile:
762
- return {"extra_args": [], "permission_mode": None, "model": None}
1451
+ return {
1452
+ "extra_args": [],
1453
+ "permission_mode": None,
1454
+ "model": None,
1455
+ "advisor_model": None,
1456
+ "env_keys": [],
1457
+ }
763
1458
  return {
764
1459
  "extra_args": list(self.profile.extra_args or []),
765
1460
  "permission_mode": self.profile.permission_mode,
766
1461
  "model": self.profile.model,
1462
+ "advisor_model": self.profile.advisor_model,
1463
+ # KEY NAMES only — profile env values may carry provider tokens and must
1464
+ # never land in the manifest.
1465
+ "env_keys": sorted((self.profile.env or {}).keys()),
767
1466
  }
768
1467
 
769
1468
  def _update_run_manifest(self, run_id: str, **updates: object) -> None:
@@ -771,11 +1470,11 @@ class CodingCliExecutor(Executor):
771
1470
  if not manifest_path.exists():
772
1471
  return
773
1472
  try:
774
- manifest = json.loads(manifest_path.read_text(encoding="utf-8")) or {}
1473
+ manifest = read_json(manifest_path) or {}
775
1474
  except json.JSONDecodeError:
776
1475
  return
777
1476
  manifest.update(updates)
778
- manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
1477
+ write_json(manifest_path, manifest)
779
1478
 
780
1479
  def _preview_lines(self, value: str | None, *, limit: int = 20) -> list[str]:
781
1480
  lines = redact_text(value or "").splitlines()
@@ -821,7 +1520,7 @@ class CodingCliExecutor(Executor):
821
1520
  "finished_at": None,
822
1521
  "duration_seconds": None,
823
1522
  }
824
- manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
1523
+ write_json(manifest_path, manifest)
825
1524
  return manifest_path
826
1525
 
827
1526
  def _write_log(self, task_client: str, result: subprocess.CompletedProcess[str]) -> None:
@@ -855,10 +1554,10 @@ class CodingCliExecutor(Executor):
855
1554
  should_write = not path.exists()
856
1555
  if path.exists():
857
1556
  try:
858
- existing = json.loads(path.read_text(encoding="utf-8")) or {}
1557
+ existing = read_json(path) or {}
859
1558
  except json.JSONDecodeError:
860
1559
  existing = {}
861
1560
  should_write = "mcpServers" in existing and "devcouncil" not in existing
862
1561
  if should_write:
863
- path.write_text(json.dumps(desired, indent=2) + "\n", encoding="utf-8")
1562
+ write_json(path, desired)
864
1563
  return path