@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,520 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Config-merge and agent-spawn regressions.
|
|
3
|
-
|
|
4
|
-
Covers two live failures observed on an OpenCode host running the installed
|
|
5
|
-
package against an operator project:
|
|
6
|
-
|
|
7
|
-
1. A pre-0.7.6 config persisted `agents.<role>.backend = ["claude", "-p"]`.
|
|
8
|
-
The shallow merge in `load_config` preserved it and it out-ranked the
|
|
9
|
-
host-native default, so an OpenCode host silently spawned Claude.
|
|
10
|
-
2. Agents were spawned with `cwd=PROJECT_ROOT` (the installed package) while
|
|
11
|
-
evidence lives in the project's `.research/`, so prompt-mandated relative
|
|
12
|
-
writes landed inside node_modules and were denied.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
import io
|
|
16
|
-
import json
|
|
17
|
-
import os
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
import tempfile
|
|
21
|
-
import time
|
|
22
|
-
import unittest
|
|
23
|
-
from contextlib import redirect_stderr
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
from unittest import mock
|
|
26
|
-
|
|
27
|
-
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
28
|
-
if str(PROJECT_ROOT) not in sys.path:
|
|
29
|
-
sys.path.insert(0, str(PROJECT_ROOT))
|
|
30
|
-
|
|
31
|
-
from runner.research_swarm import ( # noqa: E402
|
|
32
|
-
PROJECT_ROOT as PACKAGE_ROOT,
|
|
33
|
-
SwarmRunner,
|
|
34
|
-
_agent_cwd,
|
|
35
|
-
)
|
|
36
|
-
from skills.swarm_config.configure import ( # noqa: E402
|
|
37
|
-
DEFAULT_CONFIG,
|
|
38
|
-
ConfigHashError,
|
|
39
|
-
ConfigStaleError,
|
|
40
|
-
ConfigValidationError,
|
|
41
|
-
_FALLBACK_DEFAULT_CONFIG,
|
|
42
|
-
config_hash,
|
|
43
|
-
heal_config,
|
|
44
|
-
load_config,
|
|
45
|
-
merge_config,
|
|
46
|
-
migrate_legacy_agent_backends,
|
|
47
|
-
save_config,
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _write_config(base_dir: Path, payload: dict) -> Path:
|
|
52
|
-
base_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
-
cfg_file = base_dir / "config.json"
|
|
54
|
-
cfg_file.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
55
|
-
return cfg_file
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class TestFallbackDrift(unittest.TestCase):
|
|
59
|
-
"""Phase-03 R2: the standalone fallback literal tracks the canonical defaults.
|
|
60
|
-
|
|
61
|
-
Replaces the old comment-only "drift guard" (which named a test that never
|
|
62
|
-
existed): this is the real test. `_FALLBACK_DEFAULT_CONFIG` is the
|
|
63
|
-
standalone path of `DEFAULT_CONFIG`, so any key added to
|
|
64
|
-
`runner.schemas.CONFIG_DEFAULTS` without updating the literal fails here.
|
|
65
|
-
"""
|
|
66
|
-
|
|
67
|
-
def test_fallback_literal_matches_canonical_defaults(self):
|
|
68
|
-
from runner.schemas import CONFIG_DEFAULTS
|
|
69
|
-
|
|
70
|
-
self.assertEqual(_FALLBACK_DEFAULT_CONFIG, CONFIG_DEFAULTS)
|
|
71
|
-
|
|
72
|
-
def test_fallback_literal_covers_every_schema_key(self):
|
|
73
|
-
from runner.schemas import CONFIG
|
|
74
|
-
|
|
75
|
-
self.assertEqual(set(_FALLBACK_DEFAULT_CONFIG), set(CONFIG["properties"]))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class TestLegacyAgentPinMigration(unittest.TestCase):
|
|
79
|
-
def test_legacy_pin_is_dropped_when_backend_is_auto(self):
|
|
80
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
81
|
-
base = Path(tmp) / ".research"
|
|
82
|
-
_write_config(
|
|
83
|
-
base,
|
|
84
|
-
{
|
|
85
|
-
"backend": "auto",
|
|
86
|
-
"agents": {
|
|
87
|
-
"alpha": {"backend": ["claude", "-p"], "model": None},
|
|
88
|
-
"beta": {"backend": ["claude", "-p"], "model": None},
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
)
|
|
92
|
-
cfg = load_config(str(base))
|
|
93
|
-
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
94
|
-
self.assertIsNone(cfg["agents"]["beta"]["backend"])
|
|
95
|
-
|
|
96
|
-
def test_legacy_pin_no_longer_forces_claude_on_opencode_host(self):
|
|
97
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
98
|
-
base = Path(tmp) / ".research"
|
|
99
|
-
_write_config(
|
|
100
|
-
base,
|
|
101
|
-
{
|
|
102
|
-
"backend": "auto",
|
|
103
|
-
"agents": {
|
|
104
|
-
"alpha": {"backend": ["claude", "-p"], "model": None},
|
|
105
|
-
"beta": {"backend": ["claude", "-p"], "model": None},
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
)
|
|
109
|
-
with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
|
|
110
|
-
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
|
|
111
|
-
cmd = runner.build_agent_cmd("objective", role="alpha")
|
|
112
|
-
self.assertEqual(cmd[:2], ["opencode", "run"])
|
|
113
|
-
|
|
114
|
-
def test_explicit_claude_choice_is_preserved(self):
|
|
115
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
116
|
-
base = Path(tmp) / ".research"
|
|
117
|
-
_write_config(
|
|
118
|
-
base,
|
|
119
|
-
{
|
|
120
|
-
"backend": "claude",
|
|
121
|
-
"agents": {
|
|
122
|
-
"alpha": {"backend": ["claude", "-p"], "model": None},
|
|
123
|
-
"beta": {"backend": ["claude", "-p"], "model": None},
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
)
|
|
127
|
-
with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
|
|
128
|
-
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
|
|
129
|
-
with redirect_stderr(io.StringIO()):
|
|
130
|
-
cmd = runner.build_agent_cmd("objective", role="alpha")
|
|
131
|
-
self.assertEqual(cmd[:2], ["claude", "-p"])
|
|
132
|
-
|
|
133
|
-
def test_backend_opencode_top_level_drops_legacy_pin(self):
|
|
134
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
135
|
-
base = Path(tmp) / ".research"
|
|
136
|
-
_write_config(
|
|
137
|
-
base,
|
|
138
|
-
{
|
|
139
|
-
"backend": "opencode",
|
|
140
|
-
"agents": {
|
|
141
|
-
"alpha": {"backend": ["claude", "-p"], "model": None},
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
)
|
|
145
|
-
cfg = load_config(str(base))
|
|
146
|
-
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
147
|
-
|
|
148
|
-
def test_migration_helper_reports_change(self):
|
|
149
|
-
cfg = {
|
|
150
|
-
"backend": "auto",
|
|
151
|
-
"agents": {"alpha": {"backend": ["claude", "-p"]}},
|
|
152
|
-
}
|
|
153
|
-
self.assertTrue(migrate_legacy_agent_backends(cfg))
|
|
154
|
-
self.assertFalse(migrate_legacy_agent_backends(cfg))
|
|
155
|
-
|
|
156
|
-
def test_heal_config_persists_and_is_idempotent(self):
|
|
157
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
158
|
-
base = Path(tmp) / ".research"
|
|
159
|
-
cfg_file = _write_config(
|
|
160
|
-
base,
|
|
161
|
-
{
|
|
162
|
-
"backend": "auto",
|
|
163
|
-
"agents": {"alpha": {"backend": ["claude", "-p"], "model": None}},
|
|
164
|
-
},
|
|
165
|
-
)
|
|
166
|
-
self.assertTrue(heal_config(str(base)))
|
|
167
|
-
on_disk = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
168
|
-
self.assertIsNone(on_disk["agents"]["alpha"]["backend"])
|
|
169
|
-
# Second pass has nothing left to migrate.
|
|
170
|
-
self.assertFalse(heal_config(str(base)))
|
|
171
|
-
|
|
172
|
-
def test_heal_config_refuses_to_persist_schema_rejected_values(self):
|
|
173
|
-
"""R1 repro: a file with an invalid value must never be healed/written.
|
|
174
|
-
|
|
175
|
-
`{"max_iterations": 99, agents.alpha.backend == ["claude","-p"]}` used to
|
|
176
|
-
be rewritten by `heal_config(validate=False)`, legitimising the 99 and
|
|
177
|
-
feeding it to SwarmRunner. It must now stay in memory only.
|
|
178
|
-
"""
|
|
179
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
180
|
-
base = Path(tmp) / ".research"
|
|
181
|
-
cfg_file = _write_config(
|
|
182
|
-
base,
|
|
183
|
-
{
|
|
184
|
-
"max_iterations": 99,
|
|
185
|
-
"agents": {"alpha": {"backend": ["claude", "-p"]}},
|
|
186
|
-
},
|
|
187
|
-
)
|
|
188
|
-
before = cfg_file.read_bytes()
|
|
189
|
-
buf = io.StringIO()
|
|
190
|
-
with redirect_stderr(buf):
|
|
191
|
-
healed = heal_config(str(base))
|
|
192
|
-
self.assertFalse(healed)
|
|
193
|
-
self.assertEqual(cfg_file.read_bytes(), before)
|
|
194
|
-
self.assertIn("max_iterations", buf.getvalue())
|
|
195
|
-
|
|
196
|
-
# The in-memory migration still protects this process ...
|
|
197
|
-
cfg = load_config(str(base))
|
|
198
|
-
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
199
|
-
self.assertEqual(cfg["max_iterations"], 99)
|
|
200
|
-
# ... and the invalid value survives on disk until fixed.
|
|
201
|
-
on_disk = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
202
|
-
self.assertEqual(on_disk["max_iterations"], 99)
|
|
203
|
-
|
|
204
|
-
def test_heal_config_persists_once_invalid_value_is_fixed(self):
|
|
205
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
206
|
-
base = Path(tmp) / ".research"
|
|
207
|
-
cfg_file = _write_config(
|
|
208
|
-
base,
|
|
209
|
-
{
|
|
210
|
-
"max_iterations": 99,
|
|
211
|
-
"agents": {"alpha": {"backend": ["claude", "-p"]}},
|
|
212
|
-
},
|
|
213
|
-
)
|
|
214
|
-
# The typed path (MCP/CLI): effective config + the corrected value.
|
|
215
|
-
candidate = merge_config(load_config(str(base)), {"max_iterations": 2})
|
|
216
|
-
save_config(candidate, str(base))
|
|
217
|
-
on_disk = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
218
|
-
self.assertEqual(on_disk["max_iterations"], 2)
|
|
219
|
-
self.assertIsNone(on_disk["agents"]["alpha"]["backend"])
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
class TestConfigMergeSemantics(unittest.TestCase):
|
|
223
|
-
def test_partial_agents_block_does_not_clobber_defaults(self):
|
|
224
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
225
|
-
base = Path(tmp) / ".research"
|
|
226
|
-
_write_config(base, {"agents": {"alpha": {"model": "some-model"}}})
|
|
227
|
-
cfg = load_config(str(base))
|
|
228
|
-
self.assertEqual(cfg["agents"]["alpha"]["model"], "some-model")
|
|
229
|
-
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
230
|
-
# Beta was absent from the file and keeps its defaults.
|
|
231
|
-
self.assertIsNone(cfg["agents"]["beta"]["backend"])
|
|
232
|
-
|
|
233
|
-
def test_loaded_config_does_not_alias_default_config(self):
|
|
234
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
235
|
-
base = Path(tmp) / ".research"
|
|
236
|
-
cfg = load_config(str(base))
|
|
237
|
-
cfg["agents"]["alpha"]["backend"] = "MUTATED"
|
|
238
|
-
cfg["license_whitelist"].append("MUTATED")
|
|
239
|
-
self.assertIsNone(DEFAULT_CONFIG["agents"]["alpha"]["backend"])
|
|
240
|
-
self.assertNotIn("MUTATED", DEFAULT_CONFIG["license_whitelist"])
|
|
241
|
-
|
|
242
|
-
def test_malformed_agents_block_is_tolerated(self):
|
|
243
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
244
|
-
base = Path(tmp) / ".research"
|
|
245
|
-
_write_config(base, {"agents": None})
|
|
246
|
-
cfg = load_config(str(base))
|
|
247
|
-
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
248
|
-
|
|
249
|
-
_write_config(base, {"agents": {"alpha": None}})
|
|
250
|
-
cfg = load_config(str(base))
|
|
251
|
-
self.assertIsNone(cfg["agents"]["alpha"])
|
|
252
|
-
self.assertIsNone(cfg["agents"]["beta"]["backend"])
|
|
253
|
-
|
|
254
|
-
def test_non_object_config_falls_back_to_defaults(self):
|
|
255
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
256
|
-
base = Path(tmp) / ".research"
|
|
257
|
-
base.mkdir(parents=True, exist_ok=True)
|
|
258
|
-
(base / "config.json").write_text("[1, 2, 3]", encoding="utf-8")
|
|
259
|
-
with redirect_stderr(io.StringIO()):
|
|
260
|
-
cfg = load_config(str(base))
|
|
261
|
-
self.assertEqual(cfg["search_engine"], "duckduckgo")
|
|
262
|
-
|
|
263
|
-
def test_save_load_roundtrip_is_idempotent(self):
|
|
264
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
265
|
-
base = Path(tmp) / ".research"
|
|
266
|
-
save_config(load_config(str(base)), str(base))
|
|
267
|
-
first = load_config(str(base))
|
|
268
|
-
save_config(first, str(base))
|
|
269
|
-
self.assertEqual(first, load_config(str(base)))
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
class TestAgentSpawnWorkingDirectory(unittest.TestCase):
|
|
273
|
-
def test_project_root_is_evidence_dir_parent(self):
|
|
274
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
275
|
-
base = Path(tmp) / ".research"
|
|
276
|
-
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
|
|
277
|
-
self.assertEqual(
|
|
278
|
-
runner.project_root, Path(os.path.realpath(str(base))).parent
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
def test_agent_cwd_defaults_to_project_root(self):
|
|
282
|
-
project = Path("/tmp/some-project")
|
|
283
|
-
with mock.patch.dict(os.environ, {}, clear=True):
|
|
284
|
-
self.assertEqual(_agent_cwd(project), str(project))
|
|
285
|
-
|
|
286
|
-
def test_agent_cwd_package_override(self):
|
|
287
|
-
with mock.patch.dict(os.environ, {"IUMBTEMS_AGENT_CWD": "package"}):
|
|
288
|
-
self.assertEqual(_agent_cwd(Path("/tmp/x")), str(PACKAGE_ROOT))
|
|
289
|
-
|
|
290
|
-
def test_agent_cwd_absolute_override(self):
|
|
291
|
-
with mock.patch.dict(os.environ, {"IUMBTEMS_AGENT_CWD": "/custom/dir"}):
|
|
292
|
-
self.assertEqual(_agent_cwd(Path("/tmp/x")), "/custom/dir")
|
|
293
|
-
|
|
294
|
-
def test_scope_prompt_names_absolute_scratchpad(self):
|
|
295
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
296
|
-
base = Path(tmp) / ".research"
|
|
297
|
-
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
|
|
298
|
-
prompt = runner._scope_prompt("Alpha", {"scope_id": "s1"}, "s1")
|
|
299
|
-
expected = str(runner.state_machine.get_scope_dir("s1"))
|
|
300
|
-
self.assertIn(expected, prompt)
|
|
301
|
-
self.assertTrue(Path(expected).is_absolute())
|
|
302
|
-
|
|
303
|
-
def test_spawn_uses_project_root_not_package_root(self):
|
|
304
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
305
|
-
base = Path(tmp) / ".research"
|
|
306
|
-
runner = SwarmRunner(base_dir=base, mock_mode=False, mode="research")
|
|
307
|
-
fake = mock.Mock()
|
|
308
|
-
fake.stdout = "ok"
|
|
309
|
-
with mock.patch.dict(os.environ, {}, clear=True):
|
|
310
|
-
with mock.patch("subprocess.run", return_value=fake) as run_mock:
|
|
311
|
-
runner.run_claude_process("objective text")
|
|
312
|
-
kwargs = run_mock.call_args.kwargs
|
|
313
|
-
self.assertEqual(kwargs["cwd"], str(runner.project_root))
|
|
314
|
-
self.assertNotEqual(kwargs["cwd"], str(PACKAGE_ROOT))
|
|
315
|
-
self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
|
|
316
|
-
# `opencode run` follows $PWD, which subprocess.run(cwd=...) does not
|
|
317
|
-
# update — keep them identical.
|
|
318
|
-
self.assertEqual(kwargs["env"]["PWD"], kwargs["cwd"])
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
class TestBackendHostMismatchWarning(unittest.TestCase):
|
|
322
|
-
def test_warns_when_pin_contradicts_host(self):
|
|
323
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
324
|
-
base = Path(tmp) / ".research"
|
|
325
|
-
_write_config(
|
|
326
|
-
base,
|
|
327
|
-
{
|
|
328
|
-
"backend": "claude",
|
|
329
|
-
"agents": {"alpha": {"backend": ["claude", "-p"]}},
|
|
330
|
-
},
|
|
331
|
-
)
|
|
332
|
-
with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
|
|
333
|
-
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
|
|
334
|
-
buf = io.StringIO()
|
|
335
|
-
with redirect_stderr(buf):
|
|
336
|
-
runner.build_agent_cmd("objective", role="alpha")
|
|
337
|
-
self.assertIn("IUMBTEMS_HOST", buf.getvalue())
|
|
338
|
-
self.assertIn("alpha", buf.getvalue())
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
class TestSingleWriter(unittest.TestCase):
|
|
342
|
-
"""`save_config`: atomic, locked, validated, unknown-key preserving."""
|
|
343
|
-
|
|
344
|
-
def test_crash_between_temp_write_and_rename_keeps_original(self):
|
|
345
|
-
# F9 (recorded limitation): this proves the in-process failure path
|
|
346
|
-
# (patched os.replace) leaves the original byte-identical and cleans up
|
|
347
|
-
# the temp file. A real SIGKILL between write and rename can still leave
|
|
348
|
-
# a `config.json.*.tmp` behind — inherent to temp+rename; the config
|
|
349
|
-
# itself is never torn.
|
|
350
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
351
|
-
base = Path(tmp)
|
|
352
|
-
original = json.dumps({"search_engine": "brave", "custom_key": 1})
|
|
353
|
-
cfg_file = _write_config(base, json.loads(original))
|
|
354
|
-
before = cfg_file.read_bytes()
|
|
355
|
-
with mock.patch("os.replace", side_effect=OSError("simulated crash")):
|
|
356
|
-
with self.assertRaises(OSError):
|
|
357
|
-
save_config({"mode": "audit"}, str(base))
|
|
358
|
-
self.assertEqual(cfg_file.read_bytes(), before)
|
|
359
|
-
self.assertEqual(load_config(str(base))["mode"], "research")
|
|
360
|
-
# The failed write must not leave temp litter behind.
|
|
361
|
-
self.assertEqual(list(base.glob("config.json.*.tmp")), [])
|
|
362
|
-
|
|
363
|
-
def test_unknown_keys_on_disk_survive_a_partial_save(self):
|
|
364
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
365
|
-
base = Path(tmp)
|
|
366
|
-
cfg_file = _write_config(
|
|
367
|
-
base,
|
|
368
|
-
{
|
|
369
|
-
"search_engine": "brave",
|
|
370
|
-
"custom_key": {"nested": [1, 2]},
|
|
371
|
-
"agents": {"beta": {"model": "keep-me"}},
|
|
372
|
-
"websearch": {"provider": "searxng"},
|
|
373
|
-
},
|
|
374
|
-
)
|
|
375
|
-
save_config({"mode": "audit"}, str(base))
|
|
376
|
-
data = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
377
|
-
self.assertEqual(data["custom_key"], {"nested": [1, 2]})
|
|
378
|
-
self.assertEqual(data["websearch"], {"provider": "searxng"})
|
|
379
|
-
self.assertEqual(data["agents"]["beta"]["model"], "keep-me")
|
|
380
|
-
self.assertEqual(data["mode"], "audit")
|
|
381
|
-
self.assertEqual(data["search_engine"], "brave")
|
|
382
|
-
|
|
383
|
-
def test_nested_unknown_keys_merge_instead_of_clobber(self):
|
|
384
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
385
|
-
base = Path(tmp)
|
|
386
|
-
cfg_file = _write_config(
|
|
387
|
-
base, {"agents": {"beta": {"model": "keep-me", "extra": 1}}}
|
|
388
|
-
)
|
|
389
|
-
save_config({"agents": {"alpha": {"model": "new"}}}, str(base))
|
|
390
|
-
data = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
391
|
-
self.assertEqual(data["agents"]["beta"]["model"], "keep-me")
|
|
392
|
-
self.assertEqual(data["agents"]["alpha"]["model"], "new")
|
|
393
|
-
|
|
394
|
-
def test_expected_hash_mismatch_rejects_and_writes_nothing(self):
|
|
395
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
396
|
-
base = Path(tmp)
|
|
397
|
-
_write_config(base, {"mode": "research"})
|
|
398
|
-
stale_hash = config_hash(str(base))
|
|
399
|
-
# A concurrent writer lands a newer version.
|
|
400
|
-
cfg_file = _write_config(base, {"mode": "scout"})
|
|
401
|
-
before = cfg_file.read_bytes()
|
|
402
|
-
|
|
403
|
-
with self.assertRaises(ConfigStaleError) as ctx:
|
|
404
|
-
save_config({"mode": "audit"}, str(base), expected_hash=stale_hash)
|
|
405
|
-
self.assertEqual(cfg_file.read_bytes(), before)
|
|
406
|
-
details = ctx.exception.details
|
|
407
|
-
self.assertEqual(details["expected_hash"], stale_hash)
|
|
408
|
-
self.assertEqual(details["current_hash"], config_hash(str(base)))
|
|
409
|
-
self.assertEqual(details["config"]["mode"], "scout")
|
|
410
|
-
self.assertFalse(details["written"])
|
|
411
|
-
self.assertEqual(ctx.exception.code, "CONFIG_STALE")
|
|
412
|
-
# R5: the stale payload carries no absolute workspace path.
|
|
413
|
-
self.assertNotIn("path", details)
|
|
414
|
-
|
|
415
|
-
def test_expected_hash_prefix_match_proceeds(self):
|
|
416
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
417
|
-
base = Path(tmp)
|
|
418
|
-
_write_config(base, {"mode": "research"})
|
|
419
|
-
prefix = config_hash(str(base))[:8]
|
|
420
|
-
save_config({"mode": "audit"}, str(base), expected_hash=prefix)
|
|
421
|
-
self.assertEqual(load_config(str(base))["mode"], "audit")
|
|
422
|
-
|
|
423
|
-
def test_expected_hash_requires_at_least_eight_hex(self):
|
|
424
|
-
"""R4: a 4-hex guard is too collision-prone and is rejected outright."""
|
|
425
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
426
|
-
base = Path(tmp)
|
|
427
|
-
cfg_file = _write_config(base, {"mode": "research"})
|
|
428
|
-
before = cfg_file.read_bytes()
|
|
429
|
-
for bad in ("abcd", "abc", "deadbeefz", "zzzzzzzz", "12 34"):
|
|
430
|
-
with self.subTest(expected_hash=bad):
|
|
431
|
-
with self.assertRaises(ConfigHashError) as ctx:
|
|
432
|
-
save_config({"mode": "audit"}, str(base), expected_hash=bad)
|
|
433
|
-
self.assertEqual(ctx.exception.code, "CONFIG_INVALID_EXPECTED_HASH")
|
|
434
|
-
self.assertEqual(cfg_file.read_bytes(), before)
|
|
435
|
-
# 64-hex, 8-hex, and uppercase spellings proceed.
|
|
436
|
-
live = config_hash(str(base))
|
|
437
|
-
save_config({"mode": "audit"}, str(base), expected_hash=live.upper())
|
|
438
|
-
self.assertEqual(load_config(str(base))["mode"], "audit")
|
|
439
|
-
# None / empty string mean "no guard" (documented).
|
|
440
|
-
save_config({"mode": "scout"}, str(base), expected_hash="")
|
|
441
|
-
self.assertEqual(load_config(str(base))["mode"], "scout")
|
|
442
|
-
|
|
443
|
-
def test_expected_hash_rejects_before_any_directory_side_effect(self):
|
|
444
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
445
|
-
base = Path(tmp) / "fresh"
|
|
446
|
-
with self.assertRaises(ConfigHashError):
|
|
447
|
-
save_config({"mode": "audit"}, str(base), expected_hash="beef")
|
|
448
|
-
self.assertFalse(base.exists())
|
|
449
|
-
|
|
450
|
-
def test_expected_hash_for_absent_file_is_stale(self):
|
|
451
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
452
|
-
base = Path(tmp)
|
|
453
|
-
base.mkdir(parents=True, exist_ok=True)
|
|
454
|
-
with self.assertRaises(ConfigStaleError):
|
|
455
|
-
save_config({"mode": "audit"}, str(base), expected_hash="a" * 8)
|
|
456
|
-
self.assertFalse((base / "config.json").exists())
|
|
457
|
-
|
|
458
|
-
def test_invalid_config_is_rejected_without_write(self):
|
|
459
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
460
|
-
base = Path(tmp)
|
|
461
|
-
with self.assertRaises(ConfigValidationError) as ctx:
|
|
462
|
-
save_config({"max_iterations": 99}, str(base))
|
|
463
|
-
self.assertFalse((base / "config.json").exists())
|
|
464
|
-
self.assertTrue(any("maximum" in e for e in ctx.exception.errors))
|
|
465
|
-
self.assertEqual(
|
|
466
|
-
ctx.exception.to_dict()["code"], "CONFIG_VALIDATION_FAILED"
|
|
467
|
-
)
|
|
468
|
-
|
|
469
|
-
def test_non_object_config_is_rejected(self):
|
|
470
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
471
|
-
base = Path(tmp)
|
|
472
|
-
with self.assertRaises(ConfigValidationError):
|
|
473
|
-
save_config([1, 2, 3], str(base))
|
|
474
|
-
self.assertFalse((base / "config.json").exists())
|
|
475
|
-
|
|
476
|
-
def test_concurrent_writers_never_tear_or_lose_keys(self):
|
|
477
|
-
script = (
|
|
478
|
-
"import json, sys, time\n"
|
|
479
|
-
"sys.path.insert(0, sys.argv[1])\n"
|
|
480
|
-
"from skills.swarm_config.configure import save_config\n"
|
|
481
|
-
"base, name = sys.argv[2], sys.argv[3]\n"
|
|
482
|
-
"for i in range(20):\n"
|
|
483
|
-
" save_config({'search_engine': 'brave', 'probe_' + name: i}, base)\n"
|
|
484
|
-
" time.sleep(0.002)\n"
|
|
485
|
-
)
|
|
486
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
487
|
-
base = Path(tmp)
|
|
488
|
-
cfg_file = _write_config(base, {"mode": "research"})
|
|
489
|
-
procs = [
|
|
490
|
-
subprocess.Popen(
|
|
491
|
-
[
|
|
492
|
-
sys.executable,
|
|
493
|
-
"-c",
|
|
494
|
-
script,
|
|
495
|
-
str(PROJECT_ROOT),
|
|
496
|
-
str(base),
|
|
497
|
-
name,
|
|
498
|
-
],
|
|
499
|
-
stdout=subprocess.PIPE,
|
|
500
|
-
stderr=subprocess.PIPE,
|
|
501
|
-
text=True,
|
|
502
|
-
)
|
|
503
|
-
for name in ("a", "b")
|
|
504
|
-
]
|
|
505
|
-
# While the writers race, every observable state must be complete JSON.
|
|
506
|
-
deadline = time.time() + 30
|
|
507
|
-
while any(p.poll() is None for p in procs):
|
|
508
|
-
self.assertLess(time.time(), deadline, "writers did not finish")
|
|
509
|
-
json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
510
|
-
for proc in procs:
|
|
511
|
-
_, err = proc.communicate()
|
|
512
|
-
self.assertEqual(proc.returncode, 0, err)
|
|
513
|
-
data = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
514
|
-
self.assertEqual(data["probe_a"], 19)
|
|
515
|
-
self.assertEqual(data["probe_b"], 19)
|
|
516
|
-
self.assertEqual(list(base.glob("config.json.*.tmp")), [])
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if __name__ == "__main__":
|
|
520
|
-
unittest.main()
|