@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.
- package/index.ts +1 -0
- package/package.json +63 -139
- package/rpc.ts +1 -0
- package/server.ts +1 -0
- package/src/agents.ts +69 -0
- package/src/continue.ts +40 -0
- package/src/hooks.ts +283 -0
- package/src/panels.tsx +220 -0
- package/src/policy.ts +221 -0
- package/src/pr.ts +37 -0
- package/src/research.ts +63 -0
- package/src/rpc-def.ts +136 -0
- package/src/rpc.ts +331 -0
- package/src/runtime.ts +112 -0
- package/src/server.ts +311 -0
- package/src/session.ts +61 -0
- package/src/tools.ts +61 -0
- package/src/tui.tsx +206 -0
- package/tui.tsx +1 -0
- package/.agents/skills/brainstorming/SKILL.md +0 -19
- package/.agents/skills/code_audit/SKILL.md +0 -19
- package/.agents/skills/darkharvest/SKILL.md +0 -19
- package/.agents/skills/epistemic_search/SKILL.md +0 -19
- package/.agents/skills/factory/SKILL.md +0 -19
- package/.agents/skills/grilling/SKILL.md +0 -19
- package/.agents/skills/oss_scout/SKILL.md +0 -19
- package/.agents/skills/research_cache/SKILL.md +0 -19
- package/.agents/skills/swarm_config/SKILL.md +0 -19
- package/.claude-plugin/marketplace.json +0 -61
- package/.claude-plugin/plugin.json +0 -76
- package/.omp/README.md +0 -39
- package/.omp/SYSTEM.md +0 -17
- package/.omp/commands/audit.md +0 -10
- package/.omp/commands/brainstorming.md +0 -12
- package/.omp/commands/darkharvest.md +0 -11
- package/.omp/commands/domainexpansion.md +0 -9
- package/.omp/commands/factory.md +0 -9
- package/.omp/commands/grill.md +0 -9
- package/.omp/commands/scout.md +0 -10
- package/.omp/commands/swarm-config.md +0 -10
- package/.omp/commands/swarm.md +0 -9
- package/.omp/hooks/post/epistemic-audit.ts +0 -16
- package/.omp/hooks/pre/epistemic-redirect.ts +0 -21
- package/.omp/prompts/brainstorming.md +0 -8
- package/.omp/prompts/swarm.md +0 -8
- package/LICENSE +0 -126
- package/MARKETPLACE.md +0 -130
- package/README.md +0 -403
- package/bin/cli.js +0 -297
- package/config/claude-settings-patch.json +0 -10
- package/config/docker-compose.infra.yml +0 -23
- package/config/domain_packs/biopharma.json +0 -23
- package/config/domain_packs/legal.json +0 -19
- package/config/domain_packs/quant.json +0 -19
- package/config/mcp-research-servers.json +0 -33
- package/config/mcp_launcher.py +0 -153
- package/config/opencode-snippet.json +0 -156
- package/config/searxng_mcp.py +0 -92
- package/extensions/pi/index.js +0 -357
- package/hooks/hooks.json +0 -45
- package/install.sh +0 -473
- package/plugins/antigravity/README.md +0 -72
- package/plugins/antigravity/agents/alpha-thesis.md +0 -36
- package/plugins/antigravity/agents/beta-antithesis.md +0 -35
- package/plugins/antigravity/agents/brainstormer.md +0 -27
- package/plugins/antigravity/agents/code-auditor.md +0 -28
- package/plugins/antigravity/agents/darkharvester.md +0 -30
- package/plugins/antigravity/agents/epistemic-auditor.md +0 -28
- package/plugins/antigravity/agents/manager.md +0 -26
- package/plugins/antigravity/agents/oss-scout.md +0 -27
- package/plugins/antigravity/agents/programmer.md +0 -25
- package/plugins/antigravity/agents/qa-a.md +0 -22
- package/plugins/antigravity/agents/qa-b.md +0 -22
- package/plugins/antigravity/antigravity_hooks.py +0 -265
- package/plugins/antigravity/commands/audit.md +0 -11
- package/plugins/antigravity/commands/brainstorm.md +0 -9
- package/plugins/antigravity/commands/brainstorming.md +0 -10
- package/plugins/antigravity/commands/darkharvest.md +0 -12
- package/plugins/antigravity/commands/domainexpansion.md +0 -12
- package/plugins/antigravity/commands/factory.md +0 -14
- package/plugins/antigravity/commands/grill.md +0 -10
- package/plugins/antigravity/commands/scout.md +0 -11
- package/plugins/antigravity/commands/swarm-config.md +0 -9
- package/plugins/antigravity/commands/swarm.md +0 -12
- package/plugins/antigravity/hook_bus_bridge.js +0 -352
- package/plugins/antigravity/hooks.json +0 -44
- package/plugins/antigravity/mcp_config.json +0 -33
- package/plugins/antigravity/mcp_launcher.py +0 -58
- package/plugins/antigravity/plugin.json +0 -13
- package/plugins/antigravity/rules/AGENTS.md +0 -127
- package/plugins/antigravity/rules/epistemic-integrity.md +0 -16
- package/plugins/antigravity/skills/brainstorming/SKILL.md +0 -19
- package/plugins/antigravity/skills/code_audit/SKILL.md +0 -19
- package/plugins/antigravity/skills/darkharvest/SKILL.md +0 -19
- package/plugins/antigravity/skills/epistemic_search/SKILL.md +0 -19
- package/plugins/antigravity/skills/factory/SKILL.md +0 -19
- package/plugins/antigravity/skills/grilling/SKILL.md +0 -19
- package/plugins/antigravity/skills/oss_scout/SKILL.md +0 -19
- package/plugins/antigravity/skills/research_cache/SKILL.md +0 -19
- package/plugins/antigravity/skills/swarm_config/SKILL.md +0 -19
- package/plugins/codex/AGENTS.md.snippet +0 -10
- package/plugins/codex/README.md +0 -37
- package/plugins/codex/config.toml.snippet +0 -28
- package/plugins/codex/openai.yaml +0 -24
- package/plugins/codex/skills/brainstorming/SKILL.md +0 -19
- package/plugins/codex/skills/code_audit/SKILL.md +0 -19
- package/plugins/codex/skills/darkharvest/SKILL.md +0 -19
- package/plugins/codex/skills/epistemic_search/SKILL.md +0 -19
- package/plugins/codex/skills/factory/SKILL.md +0 -19
- package/plugins/codex/skills/grilling/SKILL.md +0 -19
- package/plugins/codex/skills/oss_scout/SKILL.md +0 -19
- package/plugins/codex/skills/research_cache/SKILL.md +0 -19
- package/plugins/codex/skills/swarm_config/SKILL.md +0 -19
- package/plugins/darkharvest/.claude-plugin/plugin.json +0 -15
- package/plugins/darkharvest/agents/harvest-proponent.md +0 -22
- package/plugins/darkharvest/agents/harvest-redteam.md +0 -20
- package/plugins/darkharvest/evals/teardown-verdict/graders/license-line.md +0 -6
- package/plugins/darkharvest/evals/teardown-verdict/graders/skill-fired.md +0 -5
- package/plugins/darkharvest/evals/teardown-verdict/prompt.md +0 -6
- package/plugins/darkharvest/skills/darkharvest/SKILL.md +0 -70
- package/plugins/darkharvest/skills/darkharvest/scripts/harvest.py +0 -289
- package/plugins/factory/.claude-plugin/plugin.json +0 -15
- package/plugins/factory/agents/factory-manager.md +0 -22
- package/plugins/factory/agents/programmer.md +0 -16
- package/plugins/factory/agents/qa-adversarial.md +0 -17
- package/plugins/factory/agents/qa-functional.md +0 -17
- package/plugins/factory/evals/gate-halt/graders/gates-first.md +0 -6
- package/plugins/factory/evals/gate-halt/graders/skill-fired.md +0 -5
- package/plugins/factory/evals/gate-halt/prompt.md +0 -6
- package/plugins/factory/skills/factory/SKILL.md +0 -61
- package/plugins/factory/skills/factory/scripts/factory.py +0 -300
- package/plugins/gemini/GEMINI.md +0 -16
- package/plugins/gemini/README.md +0 -20
- package/plugins/gemini/commands/audit.toml +0 -6
- package/plugins/gemini/commands/brainstorming.toml +0 -10
- package/plugins/gemini/commands/darkharvest.toml +0 -8
- package/plugins/gemini/commands/domainexpansion.toml +0 -7
- package/plugins/gemini/commands/factory.toml +0 -8
- package/plugins/gemini/commands/grill.toml +0 -6
- package/plugins/gemini/commands/scout.toml +0 -7
- package/plugins/gemini/commands/swarm-config.toml +0 -7
- package/plugins/gemini/commands/swarm.toml +0 -8
- package/plugins/gemini/gemini-extension.json +0 -38
- package/plugins/gemini/hooks/hooks.json +0 -11
- package/plugins/gemini/skills/brainstorming/SKILL.md +0 -19
- package/plugins/gemini/skills/code_audit/SKILL.md +0 -19
- package/plugins/gemini/skills/darkharvest/SKILL.md +0 -19
- package/plugins/gemini/skills/epistemic_search/SKILL.md +0 -19
- package/plugins/gemini/skills/factory/SKILL.md +0 -19
- package/plugins/gemini/skills/grilling/SKILL.md +0 -19
- package/plugins/gemini/skills/oss_scout/SKILL.md +0 -19
- package/plugins/gemini/skills/research_cache/SKILL.md +0 -19
- package/plugins/gemini/skills/swarm_config/SKILL.md +0 -19
- package/plugins/opencode/analysis-bridge.js +0 -620
- package/plugins/opencode/config-io.js +0 -1651
- package/plugins/opencode/dep-health.js +0 -1039
- package/plugins/opencode/hook-bus.js +0 -496
- package/plugins/opencode/index.js +0 -2992
- package/plugins/opencode/tui.js +0 -1551
- package/plugins/opencode/weight-baseline.json +0 -16
- package/plugins/opencode/weight-signals.js +0 -769
- package/plugins/research-cache/.claude-plugin/plugin.json +0 -15
- package/plugins/research-cache/skills/research_cache/SKILL.md +0 -36
- package/plugins/research-cache/skills/research_cache/__init__.py +0 -0
- package/plugins/research-cache/skills/research_cache/hasher.py +0 -302
- package/plugins/socratic-grilling/.claude-plugin/plugin.json +0 -15
- package/plugins/socratic-grilling/skills/grilling/SKILL.md +0 -61
- package/plugins/socratic-grilling/skills/grilling/__init__.py +0 -0
- package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +0 -363
- package/prompts/agent_alpha_thesis.md +0 -70
- package/prompts/agent_beta_antithesis.md +0 -78
- package/prompts/agent_brainstormer.md +0 -143
- package/prompts/agent_code_auditor.md +0 -88
- package/prompts/agent_darkharvest.md +0 -140
- package/prompts/agent_oss_scout.md +0 -78
- package/prompts/antigravity_repo_factcheck.md +0 -143
- package/prompts/base_epistemic_system.md +0 -50
- package/prompts/epistemic_auditor.md +0 -74
- package/prompts/orchestrator.md +0 -70
- package/runner/__init__.py +0 -0
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/antigravity_hooks.py +0 -265
- package/runner/auctioneer.py +0 -169
- package/runner/auditor_engine.py +0 -711
- package/runner/claim_store.py +0 -417
- package/runner/claim_witness.py +0 -412
- package/runner/darkharvest_claims.py +0 -358
- package/runner/errors.py +0 -51
- package/runner/living_dossiers.py +0 -395
- package/runner/mcp_protocol.py +0 -188
- package/runner/mcp_server.py +0 -1447
- package/runner/path_safety.py +0 -62
- package/runner/pcrb.py +0 -290
- package/runner/pcrb_verify.py +0 -267
- package/runner/preflight.py +0 -1011
- package/runner/refinement.py +0 -580
- package/runner/research_swarm.py +0 -1848
- package/runner/retrieval_log.py +0 -90
- package/runner/schema_validate.py +0 -193
- package/runner/schemas.py +0 -630
- package/runner/state_machine.py +0 -463
- package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_nk_hardening.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/auction_objective.json +0 -35
- package/runner/tests/fixtures/borderline_claims.json +0 -27
- package/runner/tests/fixtures/config_validation_cases.json +0 -276
- package/runner/tests/fixtures/ddg_anomaly_page.html +0 -176
- package/runner/tests/fixtures/ddg_results_page.html +0 -28
- package/runner/tests/fixtures/divergence_objectives.json +0 -31
- package/runner/tests/test_analysis_bridge.py +0 -450
- package/runner/tests/test_antigravity_plugin.py +0 -361
- package/runner/tests/test_auction_order.py +0 -173
- package/runner/tests/test_backends.py +0 -457
- package/runner/tests/test_bet1_spike.py +0 -95
- package/runner/tests/test_claim_store.py +0 -162
- package/runner/tests/test_claim_witness.py +0 -186
- package/runner/tests/test_claude_plugin.py +0 -170
- package/runner/tests/test_config_schema.py +0 -879
- package/runner/tests/test_contract_tooling.py +0 -312
- package/runner/tests/test_darkharvest_audit.py +0 -395
- package/runner/tests/test_dep_health.py +0 -595
- package/runner/tests/test_domain_packs.py +0 -217
- package/runner/tests/test_dossier_contract.py +0 -196
- package/runner/tests/test_factory.py +0 -258
- package/runner/tests/test_fleet_seam.py +0 -113
- package/runner/tests/test_frontier_concurrency.py +0 -288
- package/runner/tests/test_frontier_mirror_standalone.py +0 -156
- package/runner/tests/test_grounding.py +0 -183
- package/runner/tests/test_hardening_contract.py +0 -391
- package/runner/tests/test_hook_bus.py +0 -956
- package/runner/tests/test_living_dossiers.py +0 -204
- package/runner/tests/test_manifest_concurrency.py +0 -187
- package/runner/tests/test_mcp_server.py +0 -257
- package/runner/tests/test_nk_hardening.py +0 -1046
- package/runner/tests/test_opencode_ux.py +0 -2438
- package/runner/tests/test_opencode_v2_registrars.py +0 -2157
- package/runner/tests/test_pcrb.py +0 -240
- package/runner/tests/test_phase08_waiver_reopens.py +0 -539
- package/runner/tests/test_refinement.py +0 -255
- package/runner/tests/test_search_anomaly.py +0 -342
- package/runner/tests/test_search_provider.py +0 -1534
- package/runner/tests/test_search_provider_plugin.py +0 -401
- package/runner/tests/test_swarm.py +0 -444
- package/runner/tests/test_swarm_config.py +0 -520
- package/runner/tests/test_sweep_regressions.py +0 -453
- package/runner/tests/test_webcache.py +0 -271
- package/runner/tests/test_weight_signals.py +0 -401
- package/schemas/alpha_dossier.schema.json +0 -204
- package/schemas/audit_report.schema.json +0 -79
- package/schemas/beta_dossier.schema.json +0 -214
- package/schemas/config.schema.json +0 -311
- package/schemas/frontier.schema.json +0 -61
- package/schemas/manifest.schema.json +0 -49
- package/schemas/scope_manifest.schema.json +0 -47
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/scripts/auction_experiment.py +0 -188
- package/scripts/bet1_advisory_spike.py +0 -442
- package/scripts/build_adapters.py +0 -417
- package/scripts/check_docs.py +0 -66
- package/scripts/divergence_experiment.py +0 -214
- package/scripts/gen_schemas.py +0 -69
- package/skills/brainstorming/SKILL.md +0 -106
- package/skills/brainstorming/__init__.py +0 -1
- package/skills/brainstorming/scripts/brainstorm.py +0 -201
- package/skills/code_audit/SKILL.md +0 -45
- package/skills/darkharvest/SKILL.md +0 -70
- package/skills/darkharvest/scripts/harvest.py +0 -289
- package/skills/epistemic_search/SKILL.md +0 -67
- package/skills/epistemic_search/__init__.py +0 -1
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/fetch.py +0 -156
- package/skills/epistemic_search/scripts/search.py +0 -551
- package/skills/epistemic_search/scripts/webcache.py +0 -339
- package/skills/factory/SKILL.md +0 -61
- package/skills/factory/scripts/factory.py +0 -300
- package/skills/grilling/SKILL.md +0 -61
- package/skills/grilling/__init__.py +0 -0
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/grilling/socratic_tree.py +0 -363
- package/skills/oss_scout/SKILL.md +0 -49
- package/skills/research_cache/SKILL.md +0 -36
- package/skills/research_cache/__init__.py +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +0 -302
- package/skills/swarm_config/SKILL.md +0 -178
- package/skills/swarm_config/__init__.py +0 -35
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- 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()
|