@heretek-ai/epistemic-swarm 0.7.25 → 1.0.2

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 (350) hide show
  1. package/index.ts +1 -0
  2. package/package.json +63 -139
  3. package/rpc.ts +1 -0
  4. package/server.ts +1 -0
  5. package/src/agents.ts +69 -0
  6. package/src/continue.ts +40 -0
  7. package/src/hooks.ts +283 -0
  8. package/src/panels.tsx +220 -0
  9. package/src/policy.ts +221 -0
  10. package/src/pr.ts +37 -0
  11. package/src/research.ts +63 -0
  12. package/src/rpc-def.ts +136 -0
  13. package/src/rpc.ts +331 -0
  14. package/src/runtime.ts +112 -0
  15. package/src/server.ts +311 -0
  16. package/src/session.ts +61 -0
  17. package/src/tools.ts +61 -0
  18. package/src/tui.tsx +206 -0
  19. package/tui.tsx +1 -0
  20. package/.agents/skills/brainstorming/SKILL.md +0 -19
  21. package/.agents/skills/code_audit/SKILL.md +0 -19
  22. package/.agents/skills/darkharvest/SKILL.md +0 -19
  23. package/.agents/skills/epistemic_search/SKILL.md +0 -19
  24. package/.agents/skills/factory/SKILL.md +0 -19
  25. package/.agents/skills/grilling/SKILL.md +0 -19
  26. package/.agents/skills/oss_scout/SKILL.md +0 -19
  27. package/.agents/skills/research_cache/SKILL.md +0 -19
  28. package/.agents/skills/swarm_config/SKILL.md +0 -19
  29. package/.claude-plugin/marketplace.json +0 -61
  30. package/.claude-plugin/plugin.json +0 -76
  31. package/.omp/README.md +0 -39
  32. package/.omp/SYSTEM.md +0 -17
  33. package/.omp/commands/audit.md +0 -10
  34. package/.omp/commands/brainstorming.md +0 -12
  35. package/.omp/commands/darkharvest.md +0 -11
  36. package/.omp/commands/domainexpansion.md +0 -9
  37. package/.omp/commands/factory.md +0 -9
  38. package/.omp/commands/grill.md +0 -9
  39. package/.omp/commands/scout.md +0 -10
  40. package/.omp/commands/swarm-config.md +0 -10
  41. package/.omp/commands/swarm.md +0 -9
  42. package/.omp/hooks/post/epistemic-audit.ts +0 -16
  43. package/.omp/hooks/pre/epistemic-redirect.ts +0 -21
  44. package/.omp/prompts/brainstorming.md +0 -8
  45. package/.omp/prompts/swarm.md +0 -8
  46. package/LICENSE +0 -126
  47. package/MARKETPLACE.md +0 -130
  48. package/README.md +0 -403
  49. package/bin/cli.js +0 -297
  50. package/config/claude-settings-patch.json +0 -10
  51. package/config/docker-compose.infra.yml +0 -23
  52. package/config/domain_packs/biopharma.json +0 -23
  53. package/config/domain_packs/legal.json +0 -19
  54. package/config/domain_packs/quant.json +0 -19
  55. package/config/mcp-research-servers.json +0 -33
  56. package/config/mcp_launcher.py +0 -153
  57. package/config/opencode-snippet.json +0 -156
  58. package/config/searxng_mcp.py +0 -92
  59. package/extensions/pi/index.js +0 -357
  60. package/hooks/hooks.json +0 -45
  61. package/install.sh +0 -473
  62. package/plugins/antigravity/README.md +0 -72
  63. package/plugins/antigravity/agents/alpha-thesis.md +0 -36
  64. package/plugins/antigravity/agents/beta-antithesis.md +0 -35
  65. package/plugins/antigravity/agents/brainstormer.md +0 -27
  66. package/plugins/antigravity/agents/code-auditor.md +0 -28
  67. package/plugins/antigravity/agents/darkharvester.md +0 -30
  68. package/plugins/antigravity/agents/epistemic-auditor.md +0 -28
  69. package/plugins/antigravity/agents/manager.md +0 -26
  70. package/plugins/antigravity/agents/oss-scout.md +0 -27
  71. package/plugins/antigravity/agents/programmer.md +0 -25
  72. package/plugins/antigravity/agents/qa-a.md +0 -22
  73. package/plugins/antigravity/agents/qa-b.md +0 -22
  74. package/plugins/antigravity/antigravity_hooks.py +0 -265
  75. package/plugins/antigravity/commands/audit.md +0 -11
  76. package/plugins/antigravity/commands/brainstorm.md +0 -9
  77. package/plugins/antigravity/commands/brainstorming.md +0 -10
  78. package/plugins/antigravity/commands/darkharvest.md +0 -12
  79. package/plugins/antigravity/commands/domainexpansion.md +0 -12
  80. package/plugins/antigravity/commands/factory.md +0 -14
  81. package/plugins/antigravity/commands/grill.md +0 -10
  82. package/plugins/antigravity/commands/scout.md +0 -11
  83. package/plugins/antigravity/commands/swarm-config.md +0 -9
  84. package/plugins/antigravity/commands/swarm.md +0 -12
  85. package/plugins/antigravity/hook_bus_bridge.js +0 -352
  86. package/plugins/antigravity/hooks.json +0 -44
  87. package/plugins/antigravity/mcp_config.json +0 -33
  88. package/plugins/antigravity/mcp_launcher.py +0 -58
  89. package/plugins/antigravity/plugin.json +0 -13
  90. package/plugins/antigravity/rules/AGENTS.md +0 -127
  91. package/plugins/antigravity/rules/epistemic-integrity.md +0 -16
  92. package/plugins/antigravity/skills/brainstorming/SKILL.md +0 -19
  93. package/plugins/antigravity/skills/code_audit/SKILL.md +0 -19
  94. package/plugins/antigravity/skills/darkharvest/SKILL.md +0 -19
  95. package/plugins/antigravity/skills/epistemic_search/SKILL.md +0 -19
  96. package/plugins/antigravity/skills/factory/SKILL.md +0 -19
  97. package/plugins/antigravity/skills/grilling/SKILL.md +0 -19
  98. package/plugins/antigravity/skills/oss_scout/SKILL.md +0 -19
  99. package/plugins/antigravity/skills/research_cache/SKILL.md +0 -19
  100. package/plugins/antigravity/skills/swarm_config/SKILL.md +0 -19
  101. package/plugins/codex/AGENTS.md.snippet +0 -10
  102. package/plugins/codex/README.md +0 -37
  103. package/plugins/codex/config.toml.snippet +0 -28
  104. package/plugins/codex/openai.yaml +0 -24
  105. package/plugins/codex/skills/brainstorming/SKILL.md +0 -19
  106. package/plugins/codex/skills/code_audit/SKILL.md +0 -19
  107. package/plugins/codex/skills/darkharvest/SKILL.md +0 -19
  108. package/plugins/codex/skills/epistemic_search/SKILL.md +0 -19
  109. package/plugins/codex/skills/factory/SKILL.md +0 -19
  110. package/plugins/codex/skills/grilling/SKILL.md +0 -19
  111. package/plugins/codex/skills/oss_scout/SKILL.md +0 -19
  112. package/plugins/codex/skills/research_cache/SKILL.md +0 -19
  113. package/plugins/codex/skills/swarm_config/SKILL.md +0 -19
  114. package/plugins/darkharvest/.claude-plugin/plugin.json +0 -15
  115. package/plugins/darkharvest/agents/harvest-proponent.md +0 -22
  116. package/plugins/darkharvest/agents/harvest-redteam.md +0 -20
  117. package/plugins/darkharvest/evals/teardown-verdict/graders/license-line.md +0 -6
  118. package/plugins/darkharvest/evals/teardown-verdict/graders/skill-fired.md +0 -5
  119. package/plugins/darkharvest/evals/teardown-verdict/prompt.md +0 -6
  120. package/plugins/darkharvest/skills/darkharvest/SKILL.md +0 -70
  121. package/plugins/darkharvest/skills/darkharvest/scripts/harvest.py +0 -289
  122. package/plugins/factory/.claude-plugin/plugin.json +0 -15
  123. package/plugins/factory/agents/factory-manager.md +0 -22
  124. package/plugins/factory/agents/programmer.md +0 -16
  125. package/plugins/factory/agents/qa-adversarial.md +0 -17
  126. package/plugins/factory/agents/qa-functional.md +0 -17
  127. package/plugins/factory/evals/gate-halt/graders/gates-first.md +0 -6
  128. package/plugins/factory/evals/gate-halt/graders/skill-fired.md +0 -5
  129. package/plugins/factory/evals/gate-halt/prompt.md +0 -6
  130. package/plugins/factory/skills/factory/SKILL.md +0 -61
  131. package/plugins/factory/skills/factory/scripts/factory.py +0 -300
  132. package/plugins/gemini/GEMINI.md +0 -16
  133. package/plugins/gemini/README.md +0 -20
  134. package/plugins/gemini/commands/audit.toml +0 -6
  135. package/plugins/gemini/commands/brainstorming.toml +0 -10
  136. package/plugins/gemini/commands/darkharvest.toml +0 -8
  137. package/plugins/gemini/commands/domainexpansion.toml +0 -7
  138. package/plugins/gemini/commands/factory.toml +0 -8
  139. package/plugins/gemini/commands/grill.toml +0 -6
  140. package/plugins/gemini/commands/scout.toml +0 -7
  141. package/plugins/gemini/commands/swarm-config.toml +0 -7
  142. package/plugins/gemini/commands/swarm.toml +0 -8
  143. package/plugins/gemini/gemini-extension.json +0 -38
  144. package/plugins/gemini/hooks/hooks.json +0 -11
  145. package/plugins/gemini/skills/brainstorming/SKILL.md +0 -19
  146. package/plugins/gemini/skills/code_audit/SKILL.md +0 -19
  147. package/plugins/gemini/skills/darkharvest/SKILL.md +0 -19
  148. package/plugins/gemini/skills/epistemic_search/SKILL.md +0 -19
  149. package/plugins/gemini/skills/factory/SKILL.md +0 -19
  150. package/plugins/gemini/skills/grilling/SKILL.md +0 -19
  151. package/plugins/gemini/skills/oss_scout/SKILL.md +0 -19
  152. package/plugins/gemini/skills/research_cache/SKILL.md +0 -19
  153. package/plugins/gemini/skills/swarm_config/SKILL.md +0 -19
  154. package/plugins/opencode/analysis-bridge.js +0 -620
  155. package/plugins/opencode/config-io.js +0 -1651
  156. package/plugins/opencode/dep-health.js +0 -1039
  157. package/plugins/opencode/hook-bus.js +0 -496
  158. package/plugins/opencode/index.js +0 -2992
  159. package/plugins/opencode/tui.js +0 -1551
  160. package/plugins/opencode/weight-baseline.json +0 -16
  161. package/plugins/opencode/weight-signals.js +0 -769
  162. package/plugins/research-cache/.claude-plugin/plugin.json +0 -15
  163. package/plugins/research-cache/skills/research_cache/SKILL.md +0 -36
  164. package/plugins/research-cache/skills/research_cache/__init__.py +0 -0
  165. package/plugins/research-cache/skills/research_cache/hasher.py +0 -302
  166. package/plugins/socratic-grilling/.claude-plugin/plugin.json +0 -15
  167. package/plugins/socratic-grilling/skills/grilling/SKILL.md +0 -61
  168. package/plugins/socratic-grilling/skills/grilling/__init__.py +0 -0
  169. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +0 -363
  170. package/prompts/agent_alpha_thesis.md +0 -70
  171. package/prompts/agent_beta_antithesis.md +0 -78
  172. package/prompts/agent_brainstormer.md +0 -143
  173. package/prompts/agent_code_auditor.md +0 -88
  174. package/prompts/agent_darkharvest.md +0 -140
  175. package/prompts/agent_oss_scout.md +0 -78
  176. package/prompts/antigravity_repo_factcheck.md +0 -143
  177. package/prompts/base_epistemic_system.md +0 -50
  178. package/prompts/epistemic_auditor.md +0 -74
  179. package/prompts/orchestrator.md +0 -70
  180. package/runner/__init__.py +0 -0
  181. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  182. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  183. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  184. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  185. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  186. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  187. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  188. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  189. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  190. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  191. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  192. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  193. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  194. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  195. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  196. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  197. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  198. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  199. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  200. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  201. package/runner/antigravity_hooks.py +0 -265
  202. package/runner/auctioneer.py +0 -169
  203. package/runner/auditor_engine.py +0 -711
  204. package/runner/claim_store.py +0 -417
  205. package/runner/claim_witness.py +0 -412
  206. package/runner/darkharvest_claims.py +0 -358
  207. package/runner/errors.py +0 -51
  208. package/runner/living_dossiers.py +0 -395
  209. package/runner/mcp_protocol.py +0 -188
  210. package/runner/mcp_server.py +0 -1447
  211. package/runner/path_safety.py +0 -62
  212. package/runner/pcrb.py +0 -290
  213. package/runner/pcrb_verify.py +0 -267
  214. package/runner/preflight.py +0 -1011
  215. package/runner/refinement.py +0 -580
  216. package/runner/research_swarm.py +0 -1848
  217. package/runner/retrieval_log.py +0 -90
  218. package/runner/schema_validate.py +0 -193
  219. package/runner/schemas.py +0 -630
  220. package/runner/state_machine.py +0 -463
  221. package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
  222. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  223. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  224. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  225. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  226. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  227. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  228. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  229. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  230. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  231. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  232. package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
  233. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  234. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  235. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  236. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  237. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  238. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  239. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  240. package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
  241. package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
  242. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  243. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  244. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  245. package/runner/tests/__pycache__/test_nk_hardening.cpython-311.pyc +0 -0
  246. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  247. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  248. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  249. package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
  250. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  251. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  252. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  253. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  254. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  255. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  256. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  257. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  258. package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
  259. package/runner/tests/fixtures/auction_objective.json +0 -35
  260. package/runner/tests/fixtures/borderline_claims.json +0 -27
  261. package/runner/tests/fixtures/config_validation_cases.json +0 -276
  262. package/runner/tests/fixtures/ddg_anomaly_page.html +0 -176
  263. package/runner/tests/fixtures/ddg_results_page.html +0 -28
  264. package/runner/tests/fixtures/divergence_objectives.json +0 -31
  265. package/runner/tests/test_analysis_bridge.py +0 -450
  266. package/runner/tests/test_antigravity_plugin.py +0 -361
  267. package/runner/tests/test_auction_order.py +0 -173
  268. package/runner/tests/test_backends.py +0 -457
  269. package/runner/tests/test_bet1_spike.py +0 -95
  270. package/runner/tests/test_claim_store.py +0 -162
  271. package/runner/tests/test_claim_witness.py +0 -186
  272. package/runner/tests/test_claude_plugin.py +0 -170
  273. package/runner/tests/test_config_schema.py +0 -879
  274. package/runner/tests/test_contract_tooling.py +0 -312
  275. package/runner/tests/test_darkharvest_audit.py +0 -395
  276. package/runner/tests/test_dep_health.py +0 -595
  277. package/runner/tests/test_domain_packs.py +0 -217
  278. package/runner/tests/test_dossier_contract.py +0 -196
  279. package/runner/tests/test_factory.py +0 -258
  280. package/runner/tests/test_fleet_seam.py +0 -113
  281. package/runner/tests/test_frontier_concurrency.py +0 -288
  282. package/runner/tests/test_frontier_mirror_standalone.py +0 -156
  283. package/runner/tests/test_grounding.py +0 -183
  284. package/runner/tests/test_hardening_contract.py +0 -391
  285. package/runner/tests/test_hook_bus.py +0 -956
  286. package/runner/tests/test_living_dossiers.py +0 -204
  287. package/runner/tests/test_manifest_concurrency.py +0 -187
  288. package/runner/tests/test_mcp_server.py +0 -257
  289. package/runner/tests/test_nk_hardening.py +0 -1046
  290. package/runner/tests/test_opencode_ux.py +0 -2438
  291. package/runner/tests/test_opencode_v2_registrars.py +0 -2157
  292. package/runner/tests/test_pcrb.py +0 -240
  293. package/runner/tests/test_phase08_waiver_reopens.py +0 -539
  294. package/runner/tests/test_refinement.py +0 -255
  295. package/runner/tests/test_search_anomaly.py +0 -342
  296. package/runner/tests/test_search_provider.py +0 -1534
  297. package/runner/tests/test_search_provider_plugin.py +0 -401
  298. package/runner/tests/test_swarm.py +0 -444
  299. package/runner/tests/test_swarm_config.py +0 -520
  300. package/runner/tests/test_sweep_regressions.py +0 -453
  301. package/runner/tests/test_webcache.py +0 -271
  302. package/runner/tests/test_weight_signals.py +0 -401
  303. package/schemas/alpha_dossier.schema.json +0 -204
  304. package/schemas/audit_report.schema.json +0 -79
  305. package/schemas/beta_dossier.schema.json +0 -214
  306. package/schemas/config.schema.json +0 -311
  307. package/schemas/frontier.schema.json +0 -61
  308. package/schemas/manifest.schema.json +0 -49
  309. package/schemas/scope_manifest.schema.json +0 -47
  310. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  311. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  312. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  313. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  314. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  315. package/scripts/auction_experiment.py +0 -188
  316. package/scripts/bet1_advisory_spike.py +0 -442
  317. package/scripts/build_adapters.py +0 -417
  318. package/scripts/check_docs.py +0 -66
  319. package/scripts/divergence_experiment.py +0 -214
  320. package/scripts/gen_schemas.py +0 -69
  321. package/skills/brainstorming/SKILL.md +0 -106
  322. package/skills/brainstorming/__init__.py +0 -1
  323. package/skills/brainstorming/scripts/brainstorm.py +0 -201
  324. package/skills/code_audit/SKILL.md +0 -45
  325. package/skills/darkharvest/SKILL.md +0 -70
  326. package/skills/darkharvest/scripts/harvest.py +0 -289
  327. package/skills/epistemic_search/SKILL.md +0 -67
  328. package/skills/epistemic_search/__init__.py +0 -1
  329. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  330. package/skills/epistemic_search/scripts/fetch.py +0 -156
  331. package/skills/epistemic_search/scripts/search.py +0 -551
  332. package/skills/epistemic_search/scripts/webcache.py +0 -339
  333. package/skills/factory/SKILL.md +0 -61
  334. package/skills/factory/scripts/factory.py +0 -300
  335. package/skills/grilling/SKILL.md +0 -61
  336. package/skills/grilling/__init__.py +0 -0
  337. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  338. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  339. package/skills/grilling/socratic_tree.py +0 -363
  340. package/skills/oss_scout/SKILL.md +0 -49
  341. package/skills/research_cache/SKILL.md +0 -36
  342. package/skills/research_cache/__init__.py +0 -0
  343. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  344. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  345. package/skills/research_cache/hasher.py +0 -302
  346. package/skills/swarm_config/SKILL.md +0 -178
  347. package/skills/swarm_config/__init__.py +0 -35
  348. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  349. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  350. package/skills/swarm_config/configure.py +0 -1002
@@ -1,1002 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Interactive Configuration Manager for IUMBTEMS Epistemic Swarm.
4
- Reads, modifies, and persists research parameters in .research/config.json.
5
-
6
- Persistence contract (phase 01 `01-config-contract`):
7
-
8
- * `DEFAULT_CONFIG` mirrors `runner.schemas.CONFIG_DEFAULTS` (single source of
9
- truth); a test asserts they never drift.
10
- * `save_config` is the single writer: it takes the workspace advisory lock
11
- (`.research/.config.lock`), merges over whatever is already on disk so unknown
12
- keys survive, validates against the canonical CONFIG schema, and publishes via
13
- a unique temp file + `os.replace` (never a truncating write).
14
- * An optional `expected_hash` guard rejects a stale write with a structured
15
- error and a fresh snapshot; nothing is written on mismatch.
16
- * The atomic-write/lock primitives come from `runner.state_machine`; a
17
- self-contained fallback (same shape as `skills/grilling/socratic_tree.py`)
18
- keeps this file runnable where the `runner/` package is not importable.
19
- """
20
-
21
- import copy
22
- import hashlib
23
- import json
24
- import os
25
- import re
26
- import stat
27
- import sys
28
- import tempfile
29
- from contextlib import contextmanager
30
- from pathlib import Path
31
- from typing import Any, Dict, List, Optional
32
-
33
- DEFAULT_RESEARCH_DIR = ".research"
34
-
35
-
36
- def _fallback_atomic_write_json(path: Path, data: Any) -> None:
37
- """Self-contained atomic JSON write: unique temp file + os.replace."""
38
- path.parent.mkdir(parents=True, exist_ok=True)
39
- fd, tmp = tempfile.mkstemp(
40
- dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
41
- )
42
- try:
43
- with os.fdopen(fd, "w", encoding="utf-8") as f:
44
- json.dump(data, f, indent=2)
45
- os.replace(tmp, path)
46
- except BaseException:
47
- try:
48
- os.unlink(tmp)
49
- except OSError:
50
- pass
51
- raise
52
-
53
-
54
- @contextmanager
55
- def _fallback_file_lock(lock_path: Path):
56
- """Self-contained cross-process advisory lock; no-op without fcntl."""
57
- try:
58
- import fcntl as _fcntl
59
- except ImportError: # pragma: no cover - non-POSIX
60
- _fcntl = None
61
- if _fcntl is None:
62
- yield
63
- return
64
- lock_path.parent.mkdir(parents=True, exist_ok=True)
65
- with open(lock_path, "a+", encoding="utf-8") as fh:
66
- _fcntl.flock(fh.fileno(), _fcntl.LOCK_EX)
67
- try:
68
- yield
69
- finally:
70
- _fcntl.flock(fh.fileno(), _fcntl.LOCK_UN)
71
-
72
-
73
- # Reuse the runner's canonical primitives + canonical schema rather than
74
- # re-declaring them. The import is OPTIONAL: the skill must still run when the
75
- # `runner/` package is not importable (mirrors socratic_tree.py).
76
- try:
77
- PROJECT_ROOT = Path(__file__).resolve().parents[2]
78
- if str(PROJECT_ROOT) not in sys.path:
79
- sys.path.insert(0, str(PROJECT_ROOT))
80
- from runner.schema_validate import validate as _schema_validate # noqa: E402
81
- from runner.schemas import CONFIG as _CONFIG_SCHEMA # noqa: E402
82
- from runner.schemas import CONFIG_DEFAULTS as _CANONICAL_DEFAULTS # noqa: E402
83
- from runner.state_machine import _atomic_write_json, _file_lock # noqa: E402
84
- except ImportError: # pragma: no cover - standalone skill copy
85
- _schema_validate = None
86
- _CONFIG_SCHEMA = None
87
- _CANONICAL_DEFAULTS = None
88
- _atomic_write_json = _fallback_atomic_write_json
89
- _file_lock = _fallback_file_lock
90
-
91
-
92
- # Literal fallback for standalone runs (runner/ not importable). It must stay
93
- # in sync with the canonical `runner.schemas.CONFIG_DEFAULTS`; the drift test
94
- # `TestFallbackDrift` in `runner/tests/test_swarm_config.py` asserts equality.
95
- _FALLBACK_DEFAULT_CONFIG: Dict[str, Any] = {
96
- "search_engine": "duckduckgo",
97
- "max_iterations": 2,
98
- "divergence_threshold": 0.75,
99
- "mode": "research", # "research", "audit", "scout", "hybrid", "brainstorm", "darkharvest"
100
- "backend": "auto", # "auto" (host-native) | "claude" | "opencode"
101
- "cache_raw_markdown": True,
102
- "cache_ttl_days": None, # None = webcache's per-domain TTL policy
103
- "search_timeout_s": None, # None = each surface's built-in timeout
104
- "searxng_url": None, # None = SEARXNG_URL env untouched
105
- "license_whitelist": ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"],
106
- "output_dir": DEFAULT_RESEARCH_DIR, # deprecated: display-only, never a path
107
- # Scope allocation policy (Stream F): "dag" = legacy dependency-first
108
- # order; "auction" = Frontier Markets (highest expected-information-gain
109
- # bid first). Default "dag" preserves existing behavior exactly.
110
- "allocation": "dag",
111
- # Regulated Domain Pack id (Stream G) or null for the legacy constitution.
112
- # Loaded via runner.refinement.load_domain_pack (config/domain_packs/).
113
- "domain_pack": None,
114
- # Quote verification policy (see runner/schemas.py CONFIG).
115
- "verify": {"min_fuzzy_confidence": 0.88},
116
- # Per-agent backends. Asymmetry is the point (Stream E): Alpha and Beta on
117
- # different model families/weights probe divergence that prompt-level
118
- # red-teaming cannot reach. `backend` is a command list, not just a model
119
- # string, because cross-FAMILY means non-Claude processes.
120
- # None means "fall through to the host-native default" (claude -p on
121
- # Claude Code, opencode run on OpenCode) — explicit values still win.
122
- # mock mode never builds or spawns these commands.
123
- "agents": {
124
- "alpha": {"backend": None, "model": None, "opencode_agent": None},
125
- "beta": {"backend": None, "model": None, "opencode_agent": None},
126
- },
127
- "opencode_auto": None, # None = enabled unless IUMBTEMS_OPENCODE_AUTO says no
128
- "opencode_agent": None, # None = inline the prompt, no --agent
129
- # Persisted MCP-server toggle map (phase 03); mirrors CONFIG_DEFAULTS.
130
- "mcp_servers": {},
131
- }
132
-
133
- DEFAULT_CONFIG: Dict[str, Any] = copy.deepcopy(
134
- _CANONICAL_DEFAULTS if _CANONICAL_DEFAULTS is not None else _FALLBACK_DEFAULT_CONFIG
135
- )
136
-
137
-
138
- # Historical pre-0.7.6 default that was persisted verbatim into user configs.
139
- # A per-role pin equal to this value, while the top-level `backend` is not an
140
- # explicit "claude", is a stale default rather than a deliberate choice: it must
141
- # not override the host-native backend (opencode on OpenCode). Explicit intent
142
- # lives in `backend: "claude"` or `IUMBTEMS_BACKEND_<ROLE>`.
143
- LEGACY_AGENT_BACKEND_PINS = (["claude", "-p"],)
144
-
145
- AGENT_ROLES = ("alpha", "beta")
146
-
147
-
148
- # ---------------------------------------------------------------------------
149
- # structured errors
150
- # ---------------------------------------------------------------------------
151
-
152
-
153
- class ConfigError(Exception):
154
- """Structured config failure: stable code + message + details.
155
-
156
- Mirrors the `runner.errors.SwarmError` shape without importing it, so this
157
- module stays runnable standalone. MCP/CLI surfaces render `to_dict()`.
158
- """
159
-
160
- code = "CONFIG_ERROR"
161
-
162
- def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
163
- super().__init__(message)
164
- self.message = message
165
- self.details: Dict[str, Any] = dict(details or {})
166
-
167
- def to_dict(self) -> Dict[str, Any]:
168
- out: Dict[str, Any] = {"code": self.code, "message": self.message}
169
- if self.details:
170
- out["details"] = self.details
171
- return out
172
-
173
- def __str__(self) -> str:
174
- lines = [f"[{self.code}] {self.message}"]
175
- for problem in self.details.get("errors") or []:
176
- lines.append(f" - {problem}")
177
- if self.details.get("current_hash"):
178
- lines.append(f" current_hash: {self.details['current_hash']}")
179
- return "\n".join(lines)
180
-
181
-
182
- class ConfigValidationError(ConfigError):
183
- """The candidate config violates the canonical CONFIG schema."""
184
-
185
- code = "CONFIG_VALIDATION_FAILED"
186
-
187
- def __init__(self, problems: List[str]):
188
- errors = [str(p) for p in problems]
189
- super().__init__(
190
- f"Config validation failed with {len(errors)} violation(s); nothing written.",
191
- details={"errors": errors},
192
- )
193
-
194
- @property
195
- def errors(self) -> List[str]:
196
- return list(self.details.get("errors") or [])
197
-
198
-
199
- class ConfigStaleError(ConfigError):
200
- """`expected_hash` no longer matches the file on disk; nothing written."""
201
-
202
- code = "CONFIG_STALE"
203
-
204
- def __init__(
205
- self,
206
- expected_hash: str,
207
- current_hash: Optional[str],
208
- snapshot: Dict[str, Any],
209
- ):
210
- details: Dict[str, Any] = {
211
- "expected_hash": expected_hash,
212
- "current_hash": current_hash,
213
- "written": False,
214
- }
215
- details.update(snapshot)
216
- super().__init__(
217
- "Config changed on disk since it was read; refusing the stale write. "
218
- "Re-read the fresh snapshot and retry.",
219
- details=details,
220
- )
221
-
222
-
223
- class ConfigNullError(ConfigError):
224
- """A caller-supplied null targets a schema-known non-nullable key.
225
-
226
- Structural unification (R2): `save_config` emits the same
227
- `NULL_FOR_NON_NULLABLE_KEY` code as the MCP `_handle_config` pre-check,
228
- with dotted paths for nested nulls (e.g. `verify.min_fuzzy_confidence`).
229
- Scoping: only schema-known paths are reported here; nulls under
230
- unknown top-level keys or unknown roles (e.g. `agents.gamma`) carry no
231
- nullability info and are preserved-verbatim for generic validation to
232
- decide (unknown object keys are forward-compatible).
233
- """
234
-
235
- code = "NULL_FOR_NON_NULLABLE_KEY"
236
-
237
- def __init__(self, paths: List[str]):
238
- names = sorted(set(str(p) for p in paths))
239
- super().__init__(
240
- "null is not accepted for non-nullable key(s): "
241
- + ", ".join(names)
242
- + "; omit the key to leave it unchanged, or pass a valid value",
243
- details={"errors": names},
244
- )
245
-
246
- @property
247
- def null_errors(self) -> List[str]:
248
- return list(self.details.get("errors") or [])
249
-
250
- @property
251
- def errors(self) -> List[str]:
252
- return list(self.details.get("errors") or [])
253
-
254
-
255
- class ConfigHashError(ConfigError):
256
- """`expected_hash` is not a full sha256 / unique-prefix hex string."""
257
-
258
- code = "CONFIG_INVALID_EXPECTED_HASH"
259
-
260
- def __init__(self, expected_hash: Any):
261
- super().__init__(
262
- "expected_hash must be 8-64 hex characters (a full SHA-256 or a "
263
- "unique prefix of at least 8 hex chars).",
264
- details={"expected_hash": str(expected_hash)},
265
- )
266
-
267
-
268
- class ConfigUnreadableError(ConfigError):
269
- """The config file exists but is not a readable, bounded regular file.
270
-
271
- Phase-08 R2 (W17): `save_config` raises this (fail-closed, nothing
272
- written) instead of hanging on a FIFO/special device or silently merging
273
- over an unreadable/oversize file.
274
- """
275
-
276
- code = "CONFIG_UNREADABLE"
277
-
278
-
279
- # ---------------------------------------------------------------------------
280
- # bounded reads (phase-08 R2 FIFO hardening)
281
- # ---------------------------------------------------------------------------
282
-
283
- #: Cap for any single `config.json` read. The file is ~1KB in practice; the
284
- #: cap exists only so a pathological workspace entry can never wedge a reader
285
- #: on an unbounded slurp (mirrors `runner/preflight.py::STATE_MAX_BYTES`).
286
- CONFIG_MAX_BYTES = 1024 * 1024
287
-
288
-
289
- def _read_bounded_bytes(
290
- path: Path, max_bytes: int = CONFIG_MAX_BYTES
291
- ) -> Optional[bytes]:
292
- """Read at most `max_bytes` from `path` iff it is a regular file.
293
-
294
- Phase-08 R2 (W17): a FIFO named `config.json` hung `load_config` /
295
- `heal_config` on a plain `open`. Ports the `runner/preflight.py`
296
- `_read_bounded_text` semantics — `S_ISREG` on BOTH the pre-open `stat`
297
- and the post-open `fstat` (closing the stat->open race), an `O_NONBLOCK`
298
- open, and a byte cap — so a pathological workspace entry (FIFO, device,
299
- socket, directory, runaway file) can never block a config reader.
300
- Returns `None` when the path is absent, unreadable, non-regular, or
301
- over the cap.
302
-
303
- Symlinks are FOLLOWED (`os.stat`, no `O_NOFOLLOW`): the pre-fix reader
304
- followed them too, so a symlinked config keeps working; a symlink to a
305
- non-regular target is still refused by the `S_ISREG` checks. Residual
306
- (documented): a symlink swapped for a FIFO between the pre-open stat and
307
- the open races the check — the post-open `fstat` still refuses anything
308
- that is non-regular at read time.
309
- """
310
- try:
311
- if not stat.S_ISREG(os.stat(path).st_mode):
312
- return None
313
- except OSError:
314
- return None
315
- flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)
316
- try:
317
- fd = os.open(path, flags)
318
- except OSError:
319
- return None
320
- try:
321
- try:
322
- if not stat.S_ISREG(os.fstat(fd).st_mode):
323
- return None
324
- except OSError:
325
- return None
326
- chunks = []
327
- remaining = max_bytes + 1
328
- while remaining > 0:
329
- try:
330
- chunk = os.read(fd, min(remaining, 65536))
331
- except BlockingIOError:
332
- break
333
- except OSError:
334
- return None
335
- if not chunk:
336
- break
337
- chunks.append(chunk)
338
- remaining -= len(chunk)
339
- finally:
340
- os.close(fd)
341
- data = b"".join(chunks)
342
- if len(data) > max_bytes:
343
- return None
344
- return data
345
-
346
-
347
- # ---------------------------------------------------------------------------
348
- # paths + snapshots
349
- # ---------------------------------------------------------------------------
350
-
351
-
352
- def get_config_path(base_dir: str = DEFAULT_RESEARCH_DIR) -> Path:
353
- return Path(base_dir) / "config.json"
354
-
355
-
356
- def get_lock_path(base_dir: str = DEFAULT_RESEARCH_DIR) -> Path:
357
- return Path(base_dir) / ".config.lock"
358
-
359
-
360
- def config_hash(base_dir: str = DEFAULT_RESEARCH_DIR) -> Optional[str]:
361
- """SHA-256 of the config file's exact bytes, or None when absent.
362
-
363
- The hash covers the file as written (whitespace included), so it doubles as
364
- an optimistic-concurrency version for `expected_hash` / the RPC bridge.
365
- Phase-08 R2: read through the bounded reader, so a FIFO/special device
366
- yields None (it has no stable bytes to hash; a guarded write then fails
367
- closed as stale) instead of hanging the caller.
368
- """
369
- cfg_file = get_config_path(base_dir)
370
- raw = _read_bounded_bytes(cfg_file)
371
- if raw is None:
372
- return None
373
- return hashlib.sha256(raw).hexdigest()
374
-
375
-
376
- def snapshot(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
377
- """Fresh read-only snapshot: path, existence, file hash, effective config."""
378
- cfg_file = get_config_path(base_dir)
379
- return {
380
- "path": str(cfg_file),
381
- "exists": cfg_file.is_file(),
382
- "hash": config_hash(base_dir),
383
- "config": load_config(base_dir),
384
- }
385
-
386
-
387
- def normalize_expected_hash(expected_hash: Any) -> Optional[str]:
388
- """Validate/normalize an `expected_hash` guard; None means "no guard".
389
-
390
- Accepts a full 64-hex SHA-256 or a unique prefix of at least 8 hex chars
391
- (R4: 4-hex prefixes are rejected as too collision-prone for a guard). `None`
392
- and the empty string mean "no guard"; anything else malformed raises
393
- `ConfigHashError`.
394
- """
395
- if expected_hash is None:
396
- return None
397
- value = str(expected_hash).strip().lower()
398
- if not value:
399
- return None
400
- if not re.fullmatch(r"[0-9a-f]{8,64}", value):
401
- raise ConfigHashError(expected_hash)
402
- return value
403
-
404
-
405
- def _hash_matches(expected_hash: str, current_hash: Optional[str]) -> bool:
406
- """True when the (already normalized) expected hash prefixes the current one."""
407
- if not expected_hash or not current_hash:
408
- return False
409
- return current_hash.startswith(expected_hash)
410
-
411
-
412
- # ---------------------------------------------------------------------------
413
- # merging + validation
414
- # ---------------------------------------------------------------------------
415
-
416
-
417
- def merge_config(base: Any, updates: Any) -> Any:
418
- """Recursively merge `updates` over `base` (updates win; lists replace)."""
419
- if not isinstance(base, dict) or not isinstance(updates, dict):
420
- return copy.deepcopy(updates)
421
- merged = copy.deepcopy(base)
422
- for key, value in updates.items():
423
- if isinstance(value, dict) and isinstance(merged.get(key), dict):
424
- merged[key] = merge_config(merged[key], value)
425
- else:
426
- merged[key] = copy.deepcopy(value)
427
- return merged
428
-
429
-
430
- def validate_config(cfg: Any) -> List[str]:
431
- """Canonical-schema violations (dotted paths); empty list == valid.
432
-
433
- Degrades to `[]` with a warning when the runner package is not importable
434
- (standalone skill copy) — the canonical CLI/MCP surfaces always validate.
435
- """
436
- if _schema_validate is None or _CONFIG_SCHEMA is None:
437
- sys.stderr.write(
438
- "[swarm-config] Warning: runner schema validator unavailable; "
439
- "skipping canonical validation.\n"
440
- )
441
- return []
442
- return _schema_validate(cfg, _CONFIG_SCHEMA)
443
-
444
-
445
- def _read_raw_config(cfg_file: Path) -> Optional[Dict[str, Any]]:
446
- """Best-effort raw JSON object on disk (None when absent/malformed).
447
-
448
- Phase-08 R2: read through the bounded reader, so a FIFO/special device
449
- yields None (callers already treat None as absent/malformed) instead of
450
- hanging the caller.
451
- """
452
- try:
453
- raw = _read_bounded_bytes(cfg_file)
454
- if raw is None:
455
- return None
456
- data = json.loads(raw.decode("utf-8"))
457
- except (OSError, ValueError):
458
- return None
459
- return data if isinstance(data, dict) else None
460
-
461
-
462
- def _caller_schema_allows_null(subschema: Any) -> bool:
463
- """True when a canonical subschema accepts null (type includes "null")."""
464
- if not isinstance(subschema, dict):
465
- return False
466
- types = subschema.get("type")
467
- if isinstance(types, list):
468
- return "null" in types
469
- return types == "null"
470
-
471
-
472
- def collect_caller_null_violations(caller: Any) -> List[str]:
473
- """Dotted paths in caller-supplied `caller` where null is banned (R2).
474
-
475
- Shared nested-null pre-check: `save_config` raises `ConfigNullError` for
476
- these paths so ALL writers emit `NULL_FOR_NON_NULLABLE_KEY` for
477
- schema-known paths (unifying the MCP `_handle_config` pre-check). Only
478
- schema-known paths are reported; nulls under unknown top-level keys or
479
- unknown roles (e.g. `agents.gamma`) carry no nullability info and are
480
- preserved-verbatim for generic validation to decide. Degrades to `[]`
481
- when the canonical schema is unavailable (standalone skill copy).
482
- """
483
- if not isinstance(caller, dict):
484
- return []
485
- if _CONFIG_SCHEMA is None:
486
- return []
487
- props = _CONFIG_SCHEMA.get("properties") or {}
488
- found: List[str] = []
489
-
490
- def walk(value: Any, subschema: Any, path: str) -> None:
491
- if value is None:
492
- if isinstance(subschema, dict) and not _caller_schema_allows_null(
493
- subschema
494
- ):
495
- found.append(path)
496
- return
497
- if isinstance(value, dict) and isinstance(subschema, dict):
498
- sub_props = subschema.get("properties") or {}
499
- additional = subschema.get("additionalProperties")
500
- for key, child in value.items():
501
- if key in sub_props:
502
- walk(child, sub_props[key], f"{path}.{key}" if path else str(key))
503
- elif isinstance(additional, dict):
504
- walk(child, additional, f"{path}.{key}" if path else str(key))
505
- return
506
- if isinstance(value, list) and isinstance(subschema, dict):
507
- items = subschema.get("items")
508
- if isinstance(items, dict):
509
- for idx, child in enumerate(value):
510
- walk(child, items, f"{path}[{idx}]")
511
-
512
- for key, value in caller.items():
513
- if key not in props:
514
- continue # unknown top-level keys: preserved verbatim
515
- if value is None:
516
- if not _caller_schema_allows_null(props[key]):
517
- found.append(str(key))
518
- elif isinstance(value, (dict, list)):
519
- walk(value, props[key], str(key))
520
- return sorted(set(found))
521
-
522
-
523
- # ---------------------------------------------------------------------------
524
- # load / save
525
- # ---------------------------------------------------------------------------
526
-
527
-
528
- def _merge_agents(default_agents: Any, file_agents: Any) -> Dict[str, Any]:
529
- """Deep-merge the per-role `agents` block, one role/key at a time.
530
-
531
- A shallow `merged.update(data)` let a single on-disk role key clobber the
532
- whole default block, which silently re-pinned every agent to whatever the
533
- user's file happened to contain (the host-native default was never reached).
534
- """
535
- merged = copy.deepcopy(default_agents) if isinstance(default_agents, dict) else {}
536
- if not isinstance(file_agents, dict):
537
- return merged
538
- for role, role_cfg in file_agents.items():
539
- if not isinstance(role_cfg, dict):
540
- # Preserve unexpected shapes verbatim rather than crashing; the
541
- # backend resolver tolerates non-dict role configs.
542
- merged[role] = copy.deepcopy(role_cfg)
543
- continue
544
- base = merged.get(role)
545
- if isinstance(base, dict):
546
- base.update(copy.deepcopy(role_cfg))
547
- else:
548
- merged[role] = copy.deepcopy(role_cfg)
549
- return merged
550
-
551
-
552
- def migrate_legacy_agent_backends(cfg: Dict[str, Any]) -> bool:
553
- """Drop stale pre-0.7.6 `["claude", "-p"]` pins in place; True if changed.
554
-
555
- Only fires when the top-level backend is not an explicit "claude", so a user
556
- who deliberately selected Claude is never silently switched to something
557
- else. To keep Claude on an OpenCode host, set `backend: "claude"`.
558
- """
559
- if str(cfg.get("backend") or "auto").strip().lower() == "claude":
560
- return False
561
- agents = cfg.get("agents")
562
- if not isinstance(agents, dict):
563
- return False
564
- changed = False
565
- for role in AGENT_ROLES:
566
- role_cfg = agents.get(role)
567
- if (
568
- isinstance(role_cfg, dict)
569
- and role_cfg.get("backend") in LEGACY_AGENT_BACKEND_PINS
570
- ):
571
- role_cfg["backend"] = None
572
- changed = True
573
- return changed
574
-
575
-
576
- def load_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
577
- """Load config merged over defaults. Pure — never writes to disk.
578
-
579
- Applies the legacy-pin migration in memory so a stale config stops forcing
580
- Claude immediately. No production read path persists that migration (reads
581
- are non-healing by design, R12/F2): it is applied in memory on every load
582
- and persisted opportunistically the next time a write leg merges the loaded
583
- config (`configure.py` CLI save, the MCP `iumbtems_config` update leg);
584
- `heal_config` remains available as an explicit opt-in repair helper but no
585
- production read path calls it (phase-08 R3). Unknown keys are preserved
586
- verbatim (they are part of the contract's forward-compatibility rule).
587
-
588
- Phase-08 R2: the file is read through the bounded reader, so a FIFO /
589
- special device / oversize file warns once and yields defaults instead of
590
- hanging the caller.
591
- """
592
- base = copy.deepcopy(DEFAULT_CONFIG)
593
- cfg_file = get_config_path(base_dir)
594
- raw_bytes = _read_bounded_bytes(cfg_file)
595
- if raw_bytes is None:
596
- if os.path.lexists(cfg_file):
597
- sys.stderr.write(
598
- f"[swarm-config] Warning: {cfg_file} is not a readable regular "
599
- "file (or exceeds the read cap). Using defaults.\n"
600
- )
601
- return base
602
- try:
603
- data = json.loads(raw_bytes.decode("utf-8"))
604
- except Exception as e:
605
- sys.stderr.write(
606
- f"[swarm-config] Warning: Failed to parse {cfg_file}: {e}. Using defaults.\n"
607
- )
608
- return base
609
- if not isinstance(data, dict):
610
- sys.stderr.write(
611
- f"[swarm-config] Warning: {cfg_file} is not a JSON object. Using defaults.\n"
612
- )
613
- return base
614
- merged: Dict[str, Any] = {**base, **data}
615
- merged["agents"] = _merge_agents(base.get("agents", {}), data.get("agents"))
616
- # Object-valued policy blocks merge key-by-key so a partial file block keeps
617
- # the defaults of keys it does not mention.
618
- if isinstance(data.get("verify"), dict) and isinstance(base.get("verify"), dict):
619
- merged["verify"] = {**base["verify"], **data["verify"]}
620
- migrate_legacy_agent_backends(merged)
621
- return merged
622
-
623
-
624
- def heal_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> bool:
625
- """Persist a legacy agent-backend migration to disk; True if rewritten.
626
-
627
- Explicit opt-in repair helper (exported, tested). Phase-08 R3 truth: NO
628
- production read path calls this — `--show` / bare CLI, the MCP `show`
629
- leg, and `load_config` itself are strictly non-healing (R12/F2); the
630
- migration persists opportunistically when a write leg merges the loaded
631
- (in-memory-migrated) config. Call this directly when you want the
632
- migration persisted outside any other write. The write is guarded by the
633
- hash of the bytes this call read, so a migration never clobbers a
634
- concurrent writer (it simply defers to the next touch).
635
-
636
- The would-be-persisted dict is validated first (R1): a file that already
637
- fails the canonical schema is NOT rewritten — persisting it would legitimise
638
- the invalid values and feed them to SwarmRunner. The migration then stays in
639
- memory only, and a warning names every violation.
640
-
641
- Phase-08 R2: the pre-read goes through the bounded reader, so a FIFO /
642
- special device returns False instead of hanging the caller.
643
- """
644
- cfg_file = get_config_path(base_dir)
645
- if not cfg_file.exists():
646
- return False
647
- try:
648
- raw_bytes = _read_bounded_bytes(cfg_file)
649
- if raw_bytes is None:
650
- return False
651
- raw = json.loads(raw_bytes.decode("utf-8"))
652
- except Exception:
653
- return False
654
- if not isinstance(raw, dict):
655
- return False
656
- if not migrate_legacy_agent_backends(raw):
657
- return False
658
- candidate = load_config(base_dir)
659
- problems = validate_config(candidate)
660
- if problems:
661
- sys.stderr.write(
662
- "[swarm-config] Warning: not persisting the legacy ['claude', '-p'] "
663
- f"migration: {cfg_file} fails canonical validation. Fix the values "
664
- "below (iumbtems_config / configure.py) and re-run:\n"
665
- + "".join(f" - {problem}\n" for problem in problems)
666
- )
667
- return False
668
- try:
669
- save_config(
670
- candidate,
671
- base_dir,
672
- expected_hash=hashlib.sha256(raw_bytes).hexdigest(),
673
- )
674
- except ConfigStaleError:
675
- # Someone else wrote first; their file heals on the next touch.
676
- return False
677
- except ConfigValidationError:
678
- # A concurrent writer made the file invalid between our read and the
679
- # lock; never persist a schema-rejected dict.
680
- return False
681
- return True
682
-
683
-
684
- def save_config(
685
- cfg: Dict[str, Any],
686
- base_dir: str = DEFAULT_RESEARCH_DIR,
687
- expected_hash: Optional[str] = None,
688
- validate: bool = True,
689
- ) -> Path:
690
- """Atomically persist `cfg` into `<base_dir>/config.json`.
691
-
692
- Guarantees:
693
- * Cross-process advisory lock over `.research/.config.lock` around the whole
694
- read-merge-write, so concurrent writers cannot lose each other's keys.
695
- * Unknown keys already on disk survive; `cfg` keys (deep-merged) win.
696
- * Caller-supplied null for a schema-known non-nullable key (any depth)
697
- raises `ConfigNullError` (`NULL_FOR_NON_NULLABLE_KEY`) with dotted paths
698
- before anything is written — the same code as the MCP surface; nulls
699
- under unknown keys/roles stay preserved-verbatim for generic validation.
700
- * Schema validation against `runner.schemas.CONFIG`; other failures raise
701
- `ConfigValidationError` with dotted paths and write nothing. `validate`
702
- may be disabled only by a caller that has already validated the exact
703
- dict it is persisting (see `heal_config`).
704
- * `expected_hash` (full sha256 or a prefix of at least 8 hex chars) raises
705
- `ConfigStaleError` with a fresh snapshot instead of overwriting; a
706
- malformed guard raises `ConfigHashError` and writes nothing.
707
- * Publication is a unique temp file + `os.replace`: a crash between write
708
- and rename leaves the original file byte-identical.
709
- """
710
- if not isinstance(cfg, dict):
711
- raise ConfigValidationError(
712
- [
713
- f"<root>: expected object, got {type(cfg).__name__} "
714
- "(config must be a JSON object)"
715
- ]
716
- )
717
- null_paths = collect_caller_null_violations(cfg)
718
- if null_paths:
719
- raise ConfigNullError(null_paths)
720
- guard = normalize_expected_hash(expected_hash)
721
- target_dir = Path(base_dir)
722
- target_dir.mkdir(parents=True, exist_ok=True)
723
- cfg_file = target_dir / "config.json"
724
-
725
- with _file_lock(get_lock_path(base_dir)):
726
- # Phase-08 R2: bounded pre-read — a FIFO/special device here hung the
727
- # writer on a plain `read_bytes`. Fail closed (nothing written) rather
728
- # than hang or silently merge over an unreadable/oversize file.
729
- if cfg_file.is_file():
730
- existing_bytes = _read_bounded_bytes(cfg_file)
731
- if existing_bytes is None:
732
- raise ConfigUnreadableError(
733
- f"{cfg_file} exists but is not a readable, bounded "
734
- "regular file (FIFO, device, or over the read cap); "
735
- "refusing to merge over it.",
736
- details={"written": False},
737
- )
738
- elif os.path.lexists(cfg_file):
739
- # A non-file entry (FIFO, directory, dangling symlink): the old
740
- # code silently replaced it via merge-over-{} + os.replace.
741
- # Refuse instead — replacing a caller's FIFO/symlink out from
742
- # under them is never the safe default.
743
- raise ConfigUnreadableError(
744
- f"{cfg_file} exists but is not a regular file; "
745
- "refusing to merge over it.",
746
- details={"written": False},
747
- )
748
- else:
749
- existing_bytes = b""
750
- current_hash = (
751
- hashlib.sha256(existing_bytes).hexdigest() if existing_bytes else None
752
- )
753
- if guard is not None and not _hash_matches(guard, current_hash):
754
- fresh = snapshot(base_dir)
755
- # R5: no absolute workspace path in the stale snapshot payload.
756
- fresh.pop("path", None)
757
- fresh["hash"] = current_hash
758
- fresh["written"] = False
759
- raise ConfigStaleError(guard, current_hash, fresh)
760
- on_disk = _read_raw_config(cfg_file)
761
- merged = merge_config(on_disk or {}, cfg)
762
- if validate:
763
- problems = validate_config(merged)
764
- if problems:
765
- raise ConfigValidationError(problems)
766
- _atomic_write_json(cfg_file, merged)
767
- return cfg_file
768
-
769
-
770
- # ---------------------------------------------------------------------------
771
- # display
772
- # ---------------------------------------------------------------------------
773
-
774
-
775
- def display_config(cfg: Dict[str, Any]):
776
- print("\n⚙️ EPISTEMIC SWARM ACTIVE CONFIGURATION")
777
- print("========================================")
778
- print(
779
- f" 🔍 Primary Search Engine: {cfg.get('search_engine', 'duckduckgo').upper()}"
780
- )
781
- print(f" 🔄 Max Iterations (Depth): {cfg.get('max_iterations', 2)}")
782
- print(
783
- f" ⚖️ Divergence Threshold: {cfg.get('divergence_threshold', 0.75)} (advisory)"
784
- )
785
- print(f" 🎯 Operating Mode: {cfg.get('mode', 'research').upper()}")
786
- print(
787
- f" 🖥️ Agent Backend: {cfg.get('backend', 'auto').upper()} (auto = host-native)"
788
- )
789
- print(
790
- f" 💾 Raw Markdown Caching: {'ENABLED' if cfg.get('cache_raw_markdown', True) else 'DISABLED'}"
791
- )
792
- print(f" 📜 License Whitelist: {', '.join(cfg.get('license_whitelist', []))}")
793
- verify = cfg.get("verify") if isinstance(cfg.get("verify"), dict) else {}
794
- print(f" 🔬 Min Fuzzy Confidence: {verify.get('min_fuzzy_confidence', 0.88)}")
795
- print(
796
- f" 📁 Output Directory: {cfg.get('output_dir', '.research')} (deprecated)"
797
- )
798
- print("========================================\n")
799
-
800
-
801
- def run_interactive(cfg: Dict[str, Any]) -> Dict[str, Any]:
802
- print("\n🔧 Epistemic Swarm Interactive Configuration Setup")
803
- print("--------------------------------------------------")
804
-
805
- # 1. Search engine
806
- print("\nSelect Primary Search Engine:")
807
- print(" 1) DuckDuckGo (Zero-key default, free, no limits)")
808
- print(" 2) Brave Search (High accuracy SERP, requires BRAVE_SEARCH_API_KEY)")
809
- print(" 3) Firecrawl (Deep JS rendering, requires FIRECRAWL_API_KEY)")
810
- print(" 4) SearXNG (Self-hosted metasearch aggregator)")
811
- engine_choice = input(f"Choice [current: {cfg['search_engine']}]: ").strip()
812
- if engine_choice == "1":
813
- cfg["search_engine"] = "duckduckgo"
814
- elif engine_choice == "2":
815
- cfg["search_engine"] = "brave"
816
- elif engine_choice == "3":
817
- cfg["search_engine"] = "firecrawl"
818
- elif engine_choice == "4":
819
- cfg["search_engine"] = "searxng"
820
-
821
- # 2. Max iterations
822
- print("\nSelect Research Depth / Max Iterations:")
823
- print(" 1) 1 Pass — Rapid brief (low token usage)")
824
- print(" 2) 2 Passes — Standard dialectic thesis vs antithesis (recommended)")
825
- print(" 3) 3 Passes — Deep investigation")
826
- print(" 4) 4 Passes — Exhaustive multi-scope audit")
827
- depth_choice = input(f"Choice [current: {cfg['max_iterations']}]: ").strip()
828
- if depth_choice in ("1", "2", "3", "4"):
829
- cfg["max_iterations"] = int(depth_choice)
830
-
831
- # 3. Operating mode
832
- print("\nSelect Primary Operating Mode:")
833
- print(" 1) research — Empirical web & literature synthesis")
834
- print(" 2) audit — Deep codebase architecture & vulnerability audit")
835
- print(" 3) scout — Open-source software discovery & clean-room harvesting")
836
- print(" 4) hybrid — Combined codebase audit + web research")
837
- print(" 5) brainstorm — Lateral creative ideation & what-if exploration")
838
- print(" 6) darkharvest — Product competitor teardown & clean-room harvest")
839
- mode_choice = input(f"Choice [current: {cfg['mode']}]: ").strip()
840
- if mode_choice == "1":
841
- cfg["mode"] = "research"
842
- elif mode_choice == "2":
843
- cfg["mode"] = "audit"
844
- elif mode_choice == "3":
845
- cfg["mode"] = "scout"
846
- elif mode_choice == "4":
847
- cfg["mode"] = "hybrid"
848
- elif mode_choice == "5":
849
- cfg["mode"] = "brainstorm"
850
- elif mode_choice == "6":
851
- cfg["mode"] = "darkharvest"
852
-
853
- return cfg
854
-
855
-
856
- def _set_engine(cfg, value):
857
- if value.lower() in ("duckduckgo", "brave", "firecrawl", "searxng"):
858
- cfg["search_engine"] = value.lower()
859
- return True
860
- return False
861
-
862
-
863
- def _set_depth(cfg, value):
864
- try:
865
- cfg["max_iterations"] = int(value)
866
- return True
867
- except ValueError:
868
- return False
869
-
870
-
871
- def _set_mode(cfg, value):
872
- if value.lower() in (
873
- "research",
874
- "audit",
875
- "scout",
876
- "hybrid",
877
- "brainstorm",
878
- "darkharvest",
879
- ):
880
- cfg["mode"] = value.lower()
881
- return True
882
- return False
883
-
884
-
885
- def _set_divergence(cfg, value):
886
- try:
887
- cfg["divergence_threshold"] = float(value)
888
- return True
889
- except ValueError:
890
- return False
891
-
892
-
893
- def _set_backend(cfg, value):
894
- if value.lower() in ("auto", "claude", "opencode"):
895
- cfg["backend"] = value.lower()
896
- return True
897
- return False
898
-
899
-
900
- # flag spellings -> setter; each setter returns True when it changed config.
901
- _CLI_HANDLERS = [
902
- (("--engine", "-e"), _set_engine),
903
- (("--depth", "-d", "--iterations"), _set_depth),
904
- (("--mode", "-m"), _set_mode),
905
- (("--divergence",), _set_divergence),
906
- (("--backend",), _set_backend),
907
- ]
908
-
909
-
910
- def _parse_cli_updates(args: list, cfg: Dict[str, Any]) -> bool:
911
- modified = False
912
- idx = 0
913
- while idx < len(args):
914
- value = args[idx + 1] if idx + 1 < len(args) else None
915
- for flags, handler in _CLI_HANDLERS:
916
- if args[idx] in flags:
917
- if value is not None and handler(cfg, value):
918
- modified = True
919
- idx += 1
920
- break
921
- idx += 1
922
- return modified
923
-
924
-
925
- def _save_or_report(cfg: Dict[str, Any]) -> Optional[Path]:
926
- """Save the CLI-edited config; print a structured error on refusal."""
927
- try:
928
- return save_config(cfg)
929
- except ConfigError as exc:
930
- sys.stderr.write(f"\n❌ Configuration not saved.\n{exc}\n")
931
- return None
932
-
933
-
934
- def _legacy_pin_pending(base_dir: str = DEFAULT_RESEARCH_DIR) -> bool:
935
- """Would the legacy-pin migration apply on the next save? (read-only)
936
-
937
- R12/F2: the CLI read paths (`--show` / bare) must never write, so this
938
- reports a pending migration without persisting it. Mirrors `heal_config`'s
939
- decision — a legacy ``["claude", "-p"]`` pin is present AND the
940
- would-be-persisted config passes canonical validation — but never writes.
941
- """
942
- try:
943
- raw = _read_raw_config(get_config_path(base_dir))
944
- if not isinstance(raw, dict):
945
- return False
946
- if not migrate_legacy_agent_backends(raw):
947
- return False
948
- return not validate_config(load_config(base_dir))
949
- except Exception:
950
- return False
951
-
952
-
953
- def main():
954
- cfg = load_config()
955
- args = sys.argv[1:]
956
- # R12/F2: compute the pending migration READ-ONLY. Heal only when an update
957
- # is actually saved; a read (`--show` / bare) must never write.
958
- pending_migration = _legacy_pin_pending()
959
-
960
- if not args or "--show" in args:
961
- if pending_migration:
962
- print(
963
- "ℹ️ A legacy ['claude', '-p'] agent backend pin is present; it "
964
- "will migrate to the host-native default on the next save."
965
- )
966
- display_config(cfg)
967
- if not args:
968
- print("To edit settings, run:")
969
- print(" python3 skills/swarm_config/configure.py --interactive")
970
- print(
971
- " python3 skills/swarm_config/configure.py --engine duckduckgo --depth 3 --mode audit\n"
972
- )
973
- return
974
-
975
- if "--interactive" in args or "-i" in args:
976
- cfg = run_interactive(cfg)
977
- cfg_path = _save_or_report(cfg)
978
- if cfg_path is None:
979
- raise SystemExit(1)
980
- if pending_migration:
981
- print(
982
- "ℹ️ Migrated legacy ['claude', '-p'] agent backend pin → host-native default."
983
- )
984
- print(f"✅ Configuration saved to {cfg_path}")
985
- display_config(cfg)
986
- return
987
-
988
- modified = _parse_cli_updates(args, cfg)
989
- if modified:
990
- cfg_path = _save_or_report(cfg)
991
- if cfg_path is None:
992
- raise SystemExit(1)
993
- if pending_migration:
994
- print(
995
- "ℹ️ Migrated legacy ['claude', '-p'] agent backend pin → host-native default."
996
- )
997
- print(f"✅ Configuration updated and saved to {cfg_path}")
998
- display_config(cfg)
999
-
1000
-
1001
- if __name__ == "__main__":
1002
- main()