devcouncil 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/README.md +92 -33
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +56 -40
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +964 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +281 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +87 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +103 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +69 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +53 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +325 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/summary.py +21 -3
  255. package/src/devcouncil/live/tasks.py +12 -3
  256. package/src/devcouncil/live/transcripts.py +69 -2
  257. package/src/devcouncil/llm/cache.py +5 -6
  258. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  259. package/src/devcouncil/llm/provider.py +647 -73
  260. package/src/devcouncil/llm/router.py +271 -46
  261. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  262. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  263. package/src/devcouncil/optimization/skillopt.py +9 -5
  264. package/src/devcouncil/planning/arbiter_service.py +12 -3
  265. package/src/devcouncil/planning/correction_manifest.py +107 -10
  266. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  267. package/src/devcouncil/planning/plan_service.py +5 -2
  268. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  269. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  270. package/src/devcouncil/planning/question_conversion.py +56 -0
  271. package/src/devcouncil/planning/spec_service.py +9 -3
  272. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  273. package/src/devcouncil/repo/gitignore.py +1 -2
  274. package/src/devcouncil/reporting/evidence_export.py +124 -0
  275. package/src/devcouncil/reporting/evidence_html.py +210 -0
  276. package/src/devcouncil/reporting/json_report.py +16 -12
  277. package/src/devcouncil/reporting/markdown_report.py +38 -9
  278. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  279. package/src/devcouncil/reporting/report_builder.py +40 -4
  280. package/src/devcouncil/reporting/task_provenance.py +42 -0
  281. package/src/devcouncil/reporting/verdict.py +75 -0
  282. package/src/devcouncil/skills/library/README.md +1 -0
  283. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  285. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  286. package/src/devcouncil/skills/registry.py +43 -12
  287. package/src/devcouncil/storage/db.py +57 -11
  288. package/src/devcouncil/storage/models.py +6 -0
  289. package/src/devcouncil/storage/native.py +5 -3
  290. package/src/devcouncil/storage/repositories.py +50 -18
  291. package/src/devcouncil/telemetry/context.py +28 -0
  292. package/src/devcouncil/telemetry/cost.py +4 -5
  293. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  294. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  295. package/src/devcouncil/telemetry/stages.py +27 -2
  296. package/src/devcouncil/telemetry/tracker.py +50 -13
  297. package/src/devcouncil/ui/dashboard.py +120 -8
  298. package/src/devcouncil/utils/fsio.py +58 -0
  299. package/src/devcouncil/utils/git_snapshot.py +112 -0
  300. package/src/devcouncil/utils/json_persist.py +53 -0
  301. package/src/devcouncil/utils/proc.py +89 -0
  302. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  303. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  304. package/src/devcouncil/verification/checks/__init__.py +41 -0
  305. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  306. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  307. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  308. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  309. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  310. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  311. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  312. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  313. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  314. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  315. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  316. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  317. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  318. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  319. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  320. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  321. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  322. package/src/devcouncil/verification/checks/wiring.py +216 -0
  323. package/src/devcouncil/verification/claims/__init__.py +23 -0
  324. package/src/devcouncil/verification/claims/checks.py +395 -0
  325. package/src/devcouncil/verification/claims/mapper.py +168 -0
  326. package/src/devcouncil/verification/claims/models.py +39 -0
  327. package/src/devcouncil/verification/claims/transcript.py +92 -0
  328. package/src/devcouncil/verification/claims/verdict.py +88 -0
  329. package/src/devcouncil/verification/command_evidence.py +170 -0
  330. package/src/devcouncil/verification/command_malformation.py +147 -0
  331. package/src/devcouncil/verification/command_runner.py +164 -0
  332. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  333. package/src/devcouncil/verification/diff_coverage.py +151 -0
  334. package/src/devcouncil/verification/difficulty.py +296 -0
  335. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  336. package/src/devcouncil/verification/gap_ids.py +63 -0
  337. package/src/devcouncil/verification/gate_cache.py +194 -0
  338. package/src/devcouncil/verification/gate_selector.py +344 -0
  339. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  340. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  341. package/src/devcouncil/verification/incremental_check.py +241 -0
  342. package/src/devcouncil/verification/next_actions.py +60 -1
  343. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  344. package/src/devcouncil/verification/sandbox.py +38 -11
  345. package/src/devcouncil/verification/stub_detector.py +369 -0
  346. package/src/devcouncil/verification/test_resolver.py +67 -1
  347. package/src/devcouncil/verification/verifier.py +137 -1666
  348. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  349. package/src/devcouncil/verification/verify_setup.py +176 -0
  350. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  351. package/src/semantic_layer/__init__.py +58 -0
  352. package/src/semantic_layer/benchmark.py +75 -0
  353. package/src/semantic_layer/cache.py +290 -0
  354. package/src/semantic_layer/compressor.py +137 -0
  355. package/src/semantic_layer/config.py +75 -0
  356. package/src/semantic_layer/embeddings.py +69 -0
  357. package/src/semantic_layer/llm_backends.py +99 -0
  358. package/src/semantic_layer/pipeline.py +111 -0
  359. package/src/semantic_layer/router.py +128 -0
  360. package/src/semantic_layer/tuner.py +72 -0
  361. package/uv.lock +973 -9
  362. package/src/devcouncil/artifacts/migrations.py +0 -20
  363. package/src/devcouncil/artifacts/schemas.py +0 -23
  364. package/src/devcouncil/artifacts/serializer.py +0 -21
  365. package/src/devcouncil/integrations/gitnexus.py +0 -70
  366. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -0,0 +1,137 @@
1
+ """Bloom's-taxonomy routing — decide whether a task needs a worker or a thinker.
2
+
3
+ The Coordinator tags every subtask with a Bloom level and routes it: the lower three
4
+ levels (Remember / Understand / Apply) are execution and go to an **Worker**;
5
+ the upper three (Analyze / Evaluate / Create) are cognition and go to the
6
+ **Reviewer**. The classifier is a deterministic keyword heuristic — intentionally
7
+ simple, cheap and testable — with an explicit override honoured first so a
8
+ planner or human can pin a level via ``Task.difficulty`` or a tag.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from enum import IntEnum
15
+ from typing import Dict, Iterable, List, Optional
16
+
17
+ from devcouncil.campaign.roles import Rank
18
+
19
+
20
+ class BloomLevel(IntEnum):
21
+ REMEMBER = 1
22
+ UNDERSTAND = 2
23
+ APPLY = 3
24
+ ANALYZE = 4
25
+ EVALUATE = 5
26
+ CREATE = 6
27
+
28
+ @property
29
+ def label(self) -> str:
30
+ return self.name.capitalize()
31
+
32
+
33
+ # Ordered high→low so a strong verb ("architect", L6) wins over an incidental
34
+ # weak one ("list", L1) appearing in the same sentence.
35
+ _KEYWORDS: Dict[BloomLevel, List[str]] = {
36
+ BloomLevel.CREATE: [
37
+ "design", "architect", "architecture", "invent", "compose", "author",
38
+ "scaffold new", "green-field", "greenfield", "propose", "strategy",
39
+ "strategize", "plan the", "new subsystem", "from scratch",
40
+ ],
41
+ BloomLevel.EVALUATE: [
42
+ "evaluate", "assess", "review", "audit", "critique", "judge", "compare",
43
+ "trade-off", "tradeoff", "recommend", "decide", "prioritize", "quality",
44
+ "verify design", "root cause", "root-cause",
45
+ ],
46
+ BloomLevel.ANALYZE: [
47
+ "analyze", "analyse", "investigate", "diagnose", "debug", "profile",
48
+ "why", "reverse-engineer", "break down", "correlate", "trace",
49
+ "distinguish", "differentiate",
50
+ ],
51
+ BloomLevel.APPLY: [
52
+ "implement", "build", "write", "add", "create ", "fix", "refactor",
53
+ "wire", "integrate", "migrate", "port", "configure", "hook up",
54
+ "apply", "use", "run", "execute",
55
+ ],
56
+ BloomLevel.UNDERSTAND: [
57
+ "summarize", "summarise", "explain", "describe", "document", "clarify",
58
+ "interpret", "outline", "paraphrase", "restate",
59
+ ],
60
+ BloomLevel.REMEMBER: [
61
+ "list", "find", "locate", "look up", "fetch", "collect", "gather",
62
+ "identify", "name", "retrieve", "read",
63
+ ],
64
+ }
65
+
66
+ # Strong execution verbs — when present, cap classification at APPLY even if
67
+ # evaluate/analyze keywords ("review", "quality", …) also appear in the title.
68
+ _IMPLEMENTATION_OVERRIDES: List[str] = [
69
+ "implement", "fix", "add", "write", "build", "refactor", "wire", "integrate",
70
+ "migrate", "port", "configure", "hook up",
71
+ ]
72
+
73
+
74
+ _DIFFICULTY_HINT: Dict[str, BloomLevel] = {
75
+ "easy": BloomLevel.APPLY,
76
+ "normal": BloomLevel.APPLY,
77
+ "hard": BloomLevel.ANALYZE,
78
+ }
79
+
80
+
81
+ def _match(text: str, phrase: str) -> bool:
82
+ if phrase.endswith(" ") or " " in phrase:
83
+ return phrase.strip() in text
84
+ return re.search(rf"\b{re.escape(phrase)}\b", text) is not None
85
+
86
+
87
+ def classify_bloom(
88
+ text: str,
89
+ *,
90
+ override: Optional[BloomLevel] = None,
91
+ difficulty: Optional[str] = None,
92
+ ) -> BloomLevel:
93
+ """Classify free text into a :class:`BloomLevel`.
94
+
95
+ Precedence: explicit ``override`` > keyword match > ``difficulty`` hint >
96
+ default (:attr:`BloomLevel.APPLY`, the most common execution level).
97
+ """
98
+ if override is not None:
99
+ return override
100
+ haystack = (text or "").lower()
101
+ for level in sorted(_KEYWORDS, reverse=True): # CREATE(6) → REMEMBER(1)
102
+ if level is BloomLevel.APPLY:
103
+ continue
104
+ if any(_match(haystack, kw) for kw in _KEYWORDS[level]):
105
+ if level >= BloomLevel.EVALUATE and any(
106
+ _match(haystack, kw) for kw in _IMPLEMENTATION_OVERRIDES
107
+ ):
108
+ return BloomLevel.APPLY
109
+ return level
110
+ if any(_match(haystack, kw) for kw in _KEYWORDS[BloomLevel.APPLY]):
111
+ return BloomLevel.APPLY
112
+ if difficulty and difficulty.lower() in _DIFFICULTY_HINT:
113
+ return _DIFFICULTY_HINT[difficulty.lower()]
114
+ return BloomLevel.APPLY
115
+
116
+
117
+ def route_rank(level: BloomLevel) -> Rank:
118
+ """Map a Bloom level to the rank that should own it."""
119
+ return Rank.REVIEWER if level >= BloomLevel.ANALYZE else Rank.WORKER
120
+
121
+
122
+ def route_text(
123
+ text: str,
124
+ *,
125
+ override: Optional[BloomLevel] = None,
126
+ difficulty: Optional[str] = None,
127
+ ) -> Rank:
128
+ """Convenience: classify ``text`` and return the owning rank."""
129
+ return route_rank(classify_bloom(text, override=override, difficulty=difficulty))
130
+
131
+
132
+ def summarize_routing(items: Iterable[str]) -> Dict[str, int]:
133
+ """Count how a batch of task descriptions would route (for the dashboard)."""
134
+ counts = {Rank.WORKER.value: 0, Rank.REVIEWER.value: 0}
135
+ for item in items:
136
+ counts[route_text(item).value] += 1
137
+ return counts
@@ -0,0 +1,123 @@
1
+ """The campaign dashboard — the Coordinator's single source of truth for the operator.
2
+
3
+ Only the Coordinator writes ``.devcouncil/campaign/dashboard.md``. It renders the roster,
4
+ the order in play, work in progress, achievements (verified tasks) and anything
5
+ blocked, so the operator (or the Director answering for the operator) can read the state of
6
+ the campaign at a glance.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional
15
+
16
+
17
+ @dataclass
18
+ class RosterEntry:
19
+ agent: str
20
+ rank: str
21
+ status: str = "idle" # idle | working | reviewing
22
+ current_task: str = "-"
23
+
24
+
25
+ @dataclass
26
+ class DashboardState:
27
+ """Everything the dashboard renders — mutated by the campaign as it runs."""
28
+
29
+ goal: str = ""
30
+ roster: List[RosterEntry] = field(default_factory=list)
31
+ in_progress: List[str] = field(default_factory=list) # "T-001 · worker1 · Apply"
32
+ achievements: List[str] = field(default_factory=list) # verified task lines
33
+ blocked: List[str] = field(default_factory=list) # "T-002 — <gap>"
34
+ skipped: List[str] = field(default_factory=list) # unmet dependency
35
+ routing: Dict[str, int] = field(default_factory=dict)
36
+ total_tasks: int = 0
37
+ completed_tasks: int = 0
38
+ cost_usd: float = 0.0
39
+ cost_budget_usd: float | None = None
40
+
41
+ def roster_for(self, agent: str) -> Optional[RosterEntry]:
42
+ for entry in self.roster:
43
+ if entry.agent == agent:
44
+ return entry
45
+ return None
46
+
47
+
48
+ class DashboardWriter:
49
+ """Renders :class:`DashboardState` to markdown and writes it atomically-ish."""
50
+
51
+ def __init__(self, root: Path | str = Path(".")):
52
+ self.root = Path(root)
53
+ self.path = self.root / ".devcouncil" / "campaign" / "dashboard.md"
54
+
55
+ def render(self, state: DashboardState) -> str:
56
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
57
+ lines: List[str] = []
58
+ lines.append("# Director Campaign Dashboard")
59
+ lines.append("")
60
+ lines.append(f"_Updated {now} — written by the Coordinator._")
61
+ lines.append("")
62
+ lines.append("## Order")
63
+ lines.append("")
64
+ lines.append(f"> {state.goal or '(none)'}")
65
+ lines.append("")
66
+ if state.routing:
67
+ routed = ", ".join(f"{k}: {v}" for k, v in state.routing.items())
68
+ lines.append(f"Routing — {routed}")
69
+ lines.append("")
70
+
71
+ if state.total_tasks:
72
+ pct = int(100 * state.completed_tasks / state.total_tasks) if state.total_tasks else 0
73
+ lines.append("## Progress")
74
+ lines.append("")
75
+ lines.append(f"- Tasks: **{state.completed_tasks}/{state.total_tasks}** verified ({pct}%)")
76
+ if state.cost_budget_usd is not None:
77
+ lines.append(
78
+ f"- Spend: **${state.cost_usd:.4f}** / ${state.cost_budget_usd:.2f} budget"
79
+ )
80
+ elif state.cost_usd:
81
+ lines.append(f"- Spend: **${state.cost_usd:.4f}**")
82
+ lines.append("")
83
+
84
+ lines.append("## Roster")
85
+ lines.append("")
86
+ lines.append("| Agent | Rank | Status | Current |")
87
+ lines.append("| --- | --- | --- | --- |")
88
+ for e in state.roster:
89
+ lines.append(f"| {e.agent} | {e.rank} | {e.status} | {e.current_task} |")
90
+ lines.append("")
91
+
92
+ lines.append(f"## In Progress ({len(state.in_progress)})")
93
+ lines.append("")
94
+ lines.extend(f"- {row}" for row in state.in_progress)
95
+ if not state.in_progress:
96
+ lines.append("- (quiet)")
97
+ lines.append("")
98
+
99
+ lines.append(f"## Achievements ({len(state.achievements)})")
100
+ lines.append("")
101
+ lines.extend(f"- ✅ {row}" for row in state.achievements)
102
+ if not state.achievements:
103
+ lines.append("- (none yet)")
104
+ lines.append("")
105
+
106
+ if state.blocked:
107
+ lines.append(f"## Blocked ({len(state.blocked)})")
108
+ lines.append("")
109
+ lines.extend(f"- ⛔ {row}" for row in state.blocked)
110
+ lines.append("")
111
+
112
+ if state.skipped:
113
+ lines.append(f"## Skipped — unmet dependencies ({len(state.skipped)})")
114
+ lines.append("")
115
+ lines.extend(f"- ⏭️ {row}" for row in state.skipped)
116
+ lines.append("")
117
+
118
+ return "\n".join(lines).rstrip() + "\n"
119
+
120
+ def write(self, state: DashboardState) -> Path:
121
+ self.path.parent.mkdir(parents=True, exist_ok=True)
122
+ self.path.write_text(self.render(state), encoding="utf-8")
123
+ return self.path
@@ -0,0 +1,305 @@
1
+ """File-based agent mailbox — the Director message bus.
2
+
3
+ Faithful port of the original ``scripts/inbox_write.sh`` semantics:
4
+
5
+ * one YAML file per agent at ``.devcouncil/campaign/inbox/<agent>.yaml`` holding a
6
+ ``messages:`` list of ``{id, from, timestamp, type, content, read}`` entries;
7
+ * every append happens under a cross-process lock (an ``mkdir`` spin-mutex, plus
8
+ ``fcntl.flock`` where available) so concurrent Worker never corrupt a file;
9
+ * writes are atomic (temp file + :func:`os.replace`) so a reader never observes a
10
+ half-written file;
11
+ * the file is capped (all unread + newest ``MAX_READ_RETAINED`` read) so a busy
12
+ campaign cannot grow a mailbox without bound;
13
+ * self-sends are rejected (an agent may not mail itself).
14
+
15
+ Delivery is considered guaranteed the instant the write succeeds — there are no
16
+ ACKs or retries. Waking the recipient is a separate concern handled by
17
+ :mod:`devcouncil.campaign.watcher`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import tempfile
24
+ import time
25
+ import uuid
26
+ from dataclasses import dataclass
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Dict, List, Optional
30
+
31
+ import yaml
32
+
33
+ try: # POSIX advisory lock; optional, we still hold the mkdir spin-mutex without it.
34
+ import fcntl
35
+ except ImportError: # pragma: no cover - Windows
36
+ fcntl = None # type: ignore[assignment]
37
+
38
+ MAX_MESSAGES = 50
39
+ """Hard cap on messages retained per mailbox file."""
40
+
41
+ MAX_READ_RETAINED = 30
42
+ """When capping, keep every unread message plus this many newest read ones."""
43
+
44
+ LOCK_TIMEOUT_S = 5.0
45
+ """How long :meth:`Mailbox._lock` spins before giving up on the mutex."""
46
+
47
+ # Message types the watcher consumes directly (context resets, model swaps, …).
48
+ # They are delivered but excluded from the "N unread" nudge count so they never
49
+ # masquerade as work waiting for the agent.
50
+ SPECIAL_TYPES = frozenset({"clear_command", "model_switch", "cli_restart"})
51
+
52
+
53
+ def _utcnow() -> str:
54
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
+
56
+
57
+ @dataclass
58
+ class Message:
59
+ """One entry in an agent's mailbox."""
60
+
61
+ id: str
62
+ from_agent: str
63
+ timestamp: str
64
+ type: str
65
+ content: str
66
+ read: bool = False
67
+
68
+ def to_dict(self) -> Dict[str, object]:
69
+ # Persist with the original schema's ``from`` key (a Python keyword, hence
70
+ # the ``from_agent`` attribute name).
71
+ return {
72
+ "id": self.id,
73
+ "from": self.from_agent,
74
+ "timestamp": self.timestamp,
75
+ "type": self.type,
76
+ "content": self.content,
77
+ "read": self.read,
78
+ }
79
+
80
+ @classmethod
81
+ def from_dict(cls, raw: Dict[str, object]) -> "Message":
82
+ return cls(
83
+ id=str(raw.get("id", "")),
84
+ from_agent=str(raw.get("from", raw.get("from_agent", ""))),
85
+ timestamp=str(raw.get("timestamp", "")),
86
+ type=str(raw.get("type", "info")),
87
+ content=str(raw.get("content", "")),
88
+ read=bool(raw.get("read", False)),
89
+ )
90
+
91
+ @property
92
+ def is_special(self) -> bool:
93
+ return self.type in SPECIAL_TYPES
94
+
95
+
96
+ class MailboxError(RuntimeError):
97
+ """Raised when a mailbox invariant is violated (e.g. a self-send)."""
98
+
99
+
100
+ class MailboxLockTimeout(MailboxError):
101
+ """Raised when the mailbox mutex cannot be acquired within :data:`LOCK_TIMEOUT_S`."""
102
+
103
+
104
+ class Mailbox:
105
+ """Read/write access to every agent's on-disk inbox.
106
+
107
+ A single :class:`Mailbox` instance is safe to share across threads; all
108
+ mutation goes through the per-agent file lock.
109
+ """
110
+
111
+ def __init__(self, root: Path | str = Path(".")):
112
+ self.root = Path(root)
113
+ self.inbox_dir = self.root / ".devcouncil" / "campaign" / "inbox"
114
+
115
+ # -- paths -----------------------------------------------------------------
116
+
117
+ def path_for(self, agent: str) -> Path:
118
+ return self.inbox_dir / f"{agent}.yaml"
119
+
120
+ def _ensure_dir(self) -> None:
121
+ self.inbox_dir.mkdir(parents=True, exist_ok=True)
122
+
123
+ # -- locking ---------------------------------------------------------------
124
+
125
+ class _Lock:
126
+ """``with mailbox._lock(agent):`` — mkdir spin-mutex + optional flock."""
127
+
128
+ def __init__(self, path: Path):
129
+ self._mutex_dir = path.with_suffix(path.suffix + ".lock.d")
130
+ self._flock_path = path.with_suffix(path.suffix + ".flock")
131
+ self._flock_fd: Optional[int] = None
132
+ self._acquired = False
133
+
134
+ def __enter__(self) -> "Mailbox._Lock":
135
+ deadline = time.monotonic() + LOCK_TIMEOUT_S
136
+ self._mutex_dir.parent.mkdir(parents=True, exist_ok=True)
137
+ while True:
138
+ try:
139
+ self._mutex_dir.mkdir() # atomic on POSIX + Windows
140
+ self._acquired = True
141
+ break
142
+ except FileExistsError:
143
+ if time.monotonic() > deadline:
144
+ # Best-effort stale-lock recovery: if the mutex dir is very old,
145
+ # assume a crashed writer left it behind and try once more.
146
+ try:
147
+ age = time.time() - self._mutex_dir.stat().st_mtime
148
+ if age > LOCK_TIMEOUT_S * 2:
149
+ self._mutex_dir.rmdir()
150
+ continue
151
+ except OSError:
152
+ pass
153
+ raise MailboxLockTimeout(
154
+ f"could not acquire mailbox lock within {LOCK_TIMEOUT_S}s: {self._mutex_dir}"
155
+ )
156
+ time.sleep(0.02)
157
+ if fcntl is not None:
158
+ try:
159
+ self._flock_fd = os.open(self._flock_path, os.O_CREAT | os.O_RDWR, 0o644)
160
+ fcntl.flock(self._flock_fd, fcntl.LOCK_EX)
161
+ except OSError:
162
+ self._flock_fd = None
163
+ return self
164
+
165
+ def __exit__(self, *exc: object) -> None:
166
+ if self._flock_fd is not None:
167
+ try:
168
+ if fcntl is not None:
169
+ fcntl.flock(self._flock_fd, fcntl.LOCK_UN)
170
+ os.close(self._flock_fd)
171
+ except OSError:
172
+ pass
173
+ self._flock_fd = None
174
+ if self._acquired:
175
+ try:
176
+ self._mutex_dir.rmdir()
177
+ except OSError:
178
+ pass
179
+
180
+ def _lock(self, agent: str) -> "Mailbox._Lock":
181
+ self._ensure_dir()
182
+ return Mailbox._Lock(self.path_for(agent))
183
+
184
+ # -- io --------------------------------------------------------------------
185
+
186
+ def _read_raw(self, agent: str) -> List[Message]:
187
+ path = self.path_for(agent)
188
+ if not path.exists():
189
+ return []
190
+ try:
191
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
192
+ except (yaml.YAMLError, OSError):
193
+ return []
194
+ raw = data.get("messages", []) if isinstance(data, dict) else []
195
+ return [Message.from_dict(m) for m in raw if isinstance(m, dict)]
196
+
197
+ def _write_atomic(self, agent: str, messages: List[Message]) -> None:
198
+ self._ensure_dir()
199
+ path = self.path_for(agent)
200
+ payload = {"agent": agent, "messages": [m.to_dict() for m in messages]}
201
+ text = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
202
+ fd, tmp = tempfile.mkstemp(dir=str(self.inbox_dir), prefix=f".{agent}.", suffix=".tmp")
203
+ try:
204
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
205
+ fh.write(text)
206
+ fh.flush()
207
+ os.fsync(fh.fileno())
208
+ os.replace(tmp, path) # atomic swap
209
+ finally:
210
+ if os.path.exists(tmp):
211
+ os.unlink(tmp)
212
+
213
+ @staticmethod
214
+ def _cap(messages: List[Message]) -> List[Message]:
215
+ if len(messages) <= MAX_MESSAGES:
216
+ return messages
217
+ unread = [m for m in messages if not m.read]
218
+ read = [m for m in messages if m.read]
219
+ kept_read = read[-MAX_READ_RETAINED:]
220
+ # Preserve original ordering (oldest→newest) while dropping the oldest read.
221
+ keep = set(id(m) for m in unread) | set(id(m) for m in kept_read)
222
+ result = [m for m in messages if id(m) in keep]
223
+ if len(result) > MAX_MESSAGES:
224
+ # Unread dominated the cap — drop oldest messages to honor MAX_MESSAGES.
225
+ result = result[-MAX_MESSAGES:]
226
+ return result
227
+
228
+ # -- public api ------------------------------------------------------------
229
+
230
+ def send(
231
+ self,
232
+ target: str,
233
+ content: str,
234
+ type: str = "info",
235
+ from_agent: str = "director",
236
+ ) -> Message:
237
+ """Append a message to ``target``'s mailbox and return it.
238
+
239
+ Raises :class:`MailboxError` on a self-send.
240
+ """
241
+ if target == from_agent:
242
+ raise MailboxError(f"{from_agent} may not send mail to itself")
243
+ message = Message(
244
+ id=uuid.uuid4().hex[:12],
245
+ from_agent=from_agent,
246
+ timestamp=_utcnow(),
247
+ type=type,
248
+ content=content,
249
+ read=False,
250
+ )
251
+ with self._lock(target):
252
+ messages = self._read_raw(target)
253
+ messages.append(message)
254
+ self._write_atomic(target, self._cap(messages))
255
+ return message
256
+
257
+ def all(self, agent: str) -> List[Message]:
258
+ """Every message currently in ``agent``'s mailbox (oldest→newest)."""
259
+ return self._read_raw(agent)
260
+
261
+ def unread(self, agent: str) -> List[Message]:
262
+ """Unread messages in delivery order."""
263
+ return [m for m in self._read_raw(agent) if not m.read]
264
+
265
+ def count_unread(self, agent: str, exclude_special: bool = True) -> int:
266
+ """Number of unread messages — the ``N`` in an ``inboxN`` nudge."""
267
+ msgs = self.unread(agent)
268
+ if exclude_special:
269
+ msgs = [m for m in msgs if not m.is_special]
270
+ return len(msgs)
271
+
272
+ def mark_read(self, agent: str, ids: Optional[List[str]] = None) -> int:
273
+ """Mark messages read. ``ids=None`` marks all. Returns count changed."""
274
+ target_ids = set(ids) if ids is not None else None
275
+ changed = 0
276
+ with self._lock(agent):
277
+ messages = self._read_raw(agent)
278
+ for m in messages:
279
+ if not m.read and (target_ids is None or m.id in target_ids):
280
+ m.read = True
281
+ changed += 1
282
+ if changed:
283
+ self._write_atomic(agent, messages)
284
+ return changed
285
+
286
+ def drain(self, agent: str, exclude_special: bool = False) -> List[Message]:
287
+ """Return unread messages and mark them read in one locked pass."""
288
+ with self._lock(agent):
289
+ messages = self._read_raw(agent)
290
+ picked: List[Message] = []
291
+ for m in messages:
292
+ if m.read:
293
+ continue
294
+ if exclude_special and m.is_special:
295
+ continue
296
+ m.read = True
297
+ picked.append(m)
298
+ if picked:
299
+ self._write_atomic(agent, messages)
300
+ return picked
301
+
302
+ def clear(self, agent: str) -> None:
303
+ """Reset an agent's mailbox (used by campaign ``--clean``)."""
304
+ with self._lock(agent):
305
+ self._write_atomic(agent, [])
@@ -0,0 +1,91 @@
1
+ """Notifications to the operator — a port of the original ``ntfy.sh`` push.
2
+
3
+ The Coordinator (and only the Coordinator/Director) may reach the operator. When a campaign finishes
4
+ — or a task is blocked — a one-line push goes to an `ntfy <https://ntfy.sh>`_
5
+ topic so the operator can watch progress from a phone. A missing topic degrades to
6
+ a silent no-op; a failed push never crashes a campaign.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import List, Optional
13
+
14
+
15
+ class Notifier:
16
+ """Best-effort push notifier backed by ntfy.
17
+
18
+ Parameters
19
+ ----------
20
+ topic:
21
+ ntfy topic. Falls back to ``$DIRECTOR_NTFY_TOPIC``. Empty → disabled.
22
+ server:
23
+ ntfy server base URL (default the public ``https://ntfy.sh``). Falls back
24
+ to ``$DIRECTOR_NTFY_SERVER``.
25
+ enabled:
26
+ Force-enable/disable; ``None`` means "enabled iff a topic is set".
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ topic: Optional[str] = None,
32
+ server: Optional[str] = None,
33
+ enabled: Optional[bool] = None,
34
+ ):
35
+ self.topic = topic or os.environ.get("DIRECTOR_NTFY_TOPIC", "")
36
+ self.server = (server or os.environ.get("DIRECTOR_NTFY_SERVER") or "https://ntfy.sh").rstrip("/")
37
+ self.enabled = bool(self.topic) if enabled is None else enabled
38
+ # Records every push (delivered or not) for the dashboard / tests.
39
+ self.sent: List[str] = []
40
+
41
+ def notify(
42
+ self,
43
+ message: str,
44
+ title: Optional[str] = None,
45
+ priority: Optional[str] = None,
46
+ tags: Optional[List[str]] = None,
47
+ ) -> bool:
48
+ """Send a push. Returns ``True`` if it left the machine, else ``False``."""
49
+ self.sent.append(message)
50
+ if not self.enabled or not self.topic:
51
+ return False
52
+ try: # httpx is a DevCouncil dependency; import lazily to keep startup cheap.
53
+ import httpx
54
+
55
+ headers = {}
56
+ if title:
57
+ headers["Title"] = title
58
+ if priority:
59
+ headers["Priority"] = priority
60
+ if tags:
61
+ headers["Tags"] = ",".join(tags)
62
+ token = os.environ.get("DIRECTOR_NTFY_TOKEN")
63
+ if token:
64
+ headers["Authorization"] = f"Bearer {token}"
65
+ resp = httpx.post(
66
+ f"{self.server}/{self.topic}",
67
+ content=message.encode("utf-8"),
68
+ headers=headers,
69
+ timeout=5.0,
70
+ )
71
+ return resp.status_code < 400
72
+ except Exception:
73
+ # Never let a notification failure abort the campaign.
74
+ return False
75
+
76
+
77
+ class NullNotifier(Notifier):
78
+ """A notifier that records messages but never sends — the default."""
79
+
80
+ def __init__(self) -> None:
81
+ super().__init__(topic="", enabled=False)
82
+
83
+ def notify(
84
+ self,
85
+ message: str,
86
+ title: Optional[str] = None,
87
+ priority: Optional[str] = None,
88
+ tags: Optional[List[str]] = None,
89
+ ) -> bool:
90
+ self.sent.append(message)
91
+ return False