@heretek-ai/epistemic-swarm 0.7.10 → 0.7.11
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/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/prompts/agent_brainstormer.md +7 -4
- 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__/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__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/research_swarm.py +113 -33
- package/runner/state_machine.py +31 -13
- 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_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_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_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_refinement.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/test_dossier_contract.py +196 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +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/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heretek-ai/epistemic-swarm",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.11",
|
|
4
4
|
"description": "IUMBTEMS (Epistemic Swarm) — High-Integrity Dialectic Research Agent Harness for Claude Code, OpenCode V2, Pi (pi.dev), OMP (oh-my-pi), Gemini CLI, Codex CLI, and AntiGravity",
|
|
5
5
|
"main": "bin/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -81,10 +81,11 @@ Write `.research/brainstorm_<slug>.md`:
|
|
|
81
81
|
<what you deliberately did NOT propose: bug fixes, chores, refactors>
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
Also write
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
Also write your **role dossier** as JSON, to the exact path the runner names in
|
|
85
|
+
your task (Agent Alpha → `alpha_dossier.json`, Agent Beta → `beta_dossier.json`).
|
|
86
|
+
It must be dossier-shaped and `scope_id`-keyed; claims use `HYPOTHESIS` with a
|
|
87
|
+
`falsification` field, and `VERIFIED` only for ingested workspace facts with
|
|
88
|
+
`file://` pointers plus verbatim snippets.
|
|
88
89
|
|
|
89
90
|
## 4. HARD BANS
|
|
90
91
|
|
|
@@ -95,3 +96,5 @@ workspace facts with `file://` pointers and verbatim snippets).
|
|
|
95
96
|
`[HYPOTHESIS]` or `[INFERRED: <parents>]`.
|
|
96
97
|
4. No writes outside `.research/`.
|
|
97
98
|
5. No scope creep past 2 dialectic iterations without user confirmation.
|
|
99
|
+
6. Do NOT create or modify `manifest.json` in the scratchpad — it is
|
|
100
|
+
runner-owned. Emit only your role dossier (and the mode's `.md` artifact).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runner/research_swarm.py
CHANGED
|
@@ -15,7 +15,7 @@ import subprocess
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from datetime import datetime, timezone
|
|
17
17
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
18
|
-
from typing import Dict, Any, List, Optional, Tuple
|
|
18
|
+
from typing import Dict, Any, List, Optional, Sequence, Tuple
|
|
19
19
|
|
|
20
20
|
# Ensure project root is in sys.path
|
|
21
21
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
@@ -45,6 +45,13 @@ KNOWN_BACKEND_BINARIES = {"claude", "opencode", "python", "python3", "node"}
|
|
|
45
45
|
# the default follows the host; explicit flags/env/config always win.
|
|
46
46
|
OPENCODE_RUN_BASE = ["opencode", "run"]
|
|
47
47
|
|
|
48
|
+
# Dossier filenames the loader and auditor require, per dialectic role.
|
|
49
|
+
ROLE_DOSSIER_FILENAMES = {"Alpha": "alpha_dossier.json", "Beta": "beta_dossier.json"}
|
|
50
|
+
|
|
51
|
+
# Additional filenames an agent may emit for a role in a given mode. Kept small
|
|
52
|
+
# and mode-scoped: a complete run must not die on a filename drift.
|
|
53
|
+
MODE_DOSSIER_ALIASES = {"brainstorm": ("brainstorm_dossier.json",)}
|
|
54
|
+
|
|
48
55
|
# Fenced JSON block marker shared by orchestrator/dossier stdout parsers.
|
|
49
56
|
_JSON_FENCE = "```json"
|
|
50
57
|
|
|
@@ -312,19 +319,22 @@ class SwarmRunner:
|
|
|
312
319
|
)
|
|
313
320
|
|
|
314
321
|
def _scope_prompt(self, role: str, scope: Dict[str, Any], scope_id: str) -> str:
|
|
315
|
-
"""Prompt for a dialectic agent, naming
|
|
322
|
+
"""Prompt for a dialectic agent, naming its exact output contract.
|
|
316
323
|
|
|
317
|
-
The canonical prompts describe evidence paths RELATIVELY, so a bare
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
324
|
+
The canonical prompts describe evidence paths RELATIVELY, so a bare scope
|
|
325
|
+
id left the agent guessing which `.research` tree to write to — and, in
|
|
326
|
+
brainstorm mode, which dossier filename to emit. Name the absolute
|
|
327
|
+
scratchpad dir AND the role's dossier file, and forbid writing the
|
|
328
|
+
runner-owned manifest.
|
|
321
329
|
"""
|
|
330
|
+
scope_dir = self.state_machine.get_scope_dir(scope_id)
|
|
331
|
+
dossier = ROLE_DOSSIER_FILENAMES.get(role, f"{role.lower()}_dossier.json")
|
|
322
332
|
return (
|
|
323
333
|
f"Run Agent {role} ({self.mode} mode) for scope: {json.dumps(scope)}. "
|
|
324
334
|
f"Engine: {self.engine}. Depth: {self.depth}. "
|
|
325
|
-
f"
|
|
326
|
-
f"
|
|
327
|
-
f"
|
|
335
|
+
f"Write your role dossier to {scope_dir / dossier} (scope {scope_id}) "
|
|
336
|
+
f"and no other dossier file. Do NOT create or modify manifest.json in "
|
|
337
|
+
f"{scope_dir} — it is runner-owned."
|
|
328
338
|
)
|
|
329
339
|
|
|
330
340
|
def _resolve_opencode_agent(self, role: str) -> Optional[str]:
|
|
@@ -569,24 +579,78 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
569
579
|
"""Extract a dossier dict from free-form agent stdout (fence-aware)."""
|
|
570
580
|
return SwarmRunner._parse_json_block(text, require_key="scope_id")
|
|
571
581
|
|
|
582
|
+
@staticmethod
|
|
583
|
+
def _load_dossier_file(path: Path) -> Optional[Dict[str, Any]]:
|
|
584
|
+
"""Load a path as a dossier dict, or None when it is not one."""
|
|
585
|
+
try:
|
|
586
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
587
|
+
data = json.load(f)
|
|
588
|
+
except (OSError, ValueError):
|
|
589
|
+
return None
|
|
590
|
+
return data if isinstance(data, dict) and data.get("scope_id") else None
|
|
591
|
+
|
|
592
|
+
def _dossier_candidates(
|
|
593
|
+
self, dossier_path: Path, aliases: Sequence[str]
|
|
594
|
+
) -> List[Path]:
|
|
595
|
+
"""Candidate dossier paths: canonical, mode aliases, then manifest outputs.
|
|
596
|
+
|
|
597
|
+
Agents in some modes emit a differently named artifact (brainstorm writes
|
|
598
|
+
`brainstorm_dossier.json`), and the scope manifest may record an `outputs`
|
|
599
|
+
list. Accepting those keeps a complete run from dying on a filename drift.
|
|
600
|
+
"""
|
|
601
|
+
scope_dir = dossier_path.parent
|
|
602
|
+
candidates = [dossier_path]
|
|
603
|
+
candidates.extend(scope_dir / alias for alias in aliases)
|
|
604
|
+
manifest_p = scope_dir / "manifest.json"
|
|
605
|
+
if manifest_p.exists():
|
|
606
|
+
try:
|
|
607
|
+
outputs = (
|
|
608
|
+
json.loads(manifest_p.read_text(encoding="utf-8")).get("outputs")
|
|
609
|
+
or []
|
|
610
|
+
)
|
|
611
|
+
except (OSError, ValueError):
|
|
612
|
+
outputs = []
|
|
613
|
+
for name in outputs:
|
|
614
|
+
if isinstance(name, str) and name.endswith(".json"):
|
|
615
|
+
candidate = scope_dir / Path(name).name
|
|
616
|
+
if candidate.name not in ("manifest.json", "domain_model.json"):
|
|
617
|
+
candidates.append(candidate)
|
|
618
|
+
seen: set = set()
|
|
619
|
+
unique: List[Path] = []
|
|
620
|
+
for candidate in candidates:
|
|
621
|
+
if candidate not in seen:
|
|
622
|
+
seen.add(candidate)
|
|
623
|
+
unique.append(candidate)
|
|
624
|
+
return unique
|
|
625
|
+
|
|
572
626
|
def _load_agent_dossier(
|
|
573
627
|
self,
|
|
574
628
|
dossier_path: Path,
|
|
575
629
|
scope_id: str,
|
|
576
630
|
role: str,
|
|
577
631
|
transcript: Optional[str] = None,
|
|
632
|
+
aliases: Sequence[str] = (),
|
|
578
633
|
) -> Dict[str, Any]:
|
|
579
|
-
"""Load an agent dossier from disk,
|
|
634
|
+
"""Load an agent dossier from disk, tolerating mode-specific filenames.
|
|
580
635
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
recovered, not natively filed.
|
|
636
|
+
Tries the canonical path, then mode aliases, then the scope manifest's
|
|
637
|
+
`outputs`. A hit under an alias is normalized to the canonical filename so
|
|
638
|
+
the auditor and claim index stay consistent. Falls back to parsing the
|
|
639
|
+
agent's stdout, then raises a diagnostic error naming where it looked.
|
|
586
640
|
"""
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
641
|
+
for candidate in self._dossier_candidates(dossier_path, aliases):
|
|
642
|
+
data = self._load_dossier_file(candidate)
|
|
643
|
+
if data is None:
|
|
644
|
+
continue
|
|
645
|
+
if candidate != dossier_path:
|
|
646
|
+
data.setdefault("renamed_from", candidate.name)
|
|
647
|
+
dossier_path.parent.mkdir(parents=True, exist_ok=True)
|
|
648
|
+
_atomic_write_json(dossier_path, data)
|
|
649
|
+
print(
|
|
650
|
+
f" [{role}] ⚠️ Dossier found as {candidate.name}; "
|
|
651
|
+
f"normalized to {dossier_path.name}."
|
|
652
|
+
)
|
|
653
|
+
return data
|
|
590
654
|
recovered = self._parse_dossier_json(transcript or "")
|
|
591
655
|
if recovered is not None:
|
|
592
656
|
recovered.setdefault("recovered_from_stdout", True)
|
|
@@ -595,18 +659,18 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
595
659
|
print(f" [{role}] ⚠️ Dossier file missing; recovered from stdout.")
|
|
596
660
|
return recovered
|
|
597
661
|
raise FileNotFoundError(
|
|
598
|
-
self._dossier_missing_message(dossier_path, scope_id, role)
|
|
662
|
+
self._dossier_missing_message(dossier_path, scope_id, role, aliases)
|
|
599
663
|
)
|
|
600
664
|
|
|
601
665
|
def _dossier_missing_message(
|
|
602
|
-
self, dossier_path: Path, scope_id: str, role: str
|
|
666
|
+
self, dossier_path: Path, scope_id: str, role: str, aliases: Sequence[str] = ()
|
|
603
667
|
) -> str:
|
|
604
668
|
"""Diagnostic message naming the searched path and any stray copy.
|
|
605
669
|
|
|
606
670
|
The recurring failure mode is a workspace mismatch: the agent writes the
|
|
607
671
|
dossier to a different `.research` tree than the runner reads. Reporting
|
|
608
672
|
only the searched path made that invisible, so scan the plausible
|
|
609
|
-
alternates and say where the dossier
|
|
673
|
+
alternates — scoped to THIS scope id — and say where the dossier is.
|
|
610
674
|
"""
|
|
611
675
|
lines = [
|
|
612
676
|
f"{role} dossier not found at {dossier_path} and no dossier JSON "
|
|
@@ -615,10 +679,15 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
615
679
|
f" spawn cwd/PWD : {_agent_cwd(self.project_root)}",
|
|
616
680
|
f" IUMBTEMS_PROJECT_DIR: {os.environ.get('IUMBTEMS_PROJECT_DIR', '(unset)')}",
|
|
617
681
|
]
|
|
618
|
-
|
|
619
|
-
|
|
682
|
+
names = [dossier_path.name, *aliases]
|
|
683
|
+
found = None
|
|
684
|
+
for name in names:
|
|
685
|
+
found = self._locate_dossier_elsewhere(scope_id, name)
|
|
686
|
+
if found:
|
|
687
|
+
break
|
|
688
|
+
if found:
|
|
620
689
|
lines.append(
|
|
621
|
-
f" FOUND A COPY ELSEWHERE: {
|
|
690
|
+
f" FOUND A COPY ELSEWHERE: {found} — the spawned agent wrote "
|
|
622
691
|
f"to a different workspace than the runner reads"
|
|
623
692
|
)
|
|
624
693
|
else:
|
|
@@ -627,8 +696,12 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
627
696
|
)
|
|
628
697
|
return "\n".join(lines)
|
|
629
698
|
|
|
630
|
-
def _locate_dossier_elsewhere(self, filename: str) -> Optional[Path]:
|
|
631
|
-
"""
|
|
699
|
+
def _locate_dossier_elsewhere(self, scope_id: str, filename: str) -> Optional[Path]:
|
|
700
|
+
"""Find this scope's dossier in a workspace the agent might have used.
|
|
701
|
+
|
|
702
|
+
Scoped to `<root>/.research/scratchpads/<scope_id>/` so an unrelated older
|
|
703
|
+
scope holding a same-named file is never reported as a match.
|
|
704
|
+
"""
|
|
632
705
|
roots: List[Path] = []
|
|
633
706
|
pwd = os.environ.get("PWD", "").strip()
|
|
634
707
|
if pwd:
|
|
@@ -654,10 +727,9 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
654
727
|
except (OSError, subprocess.SubprocessError):
|
|
655
728
|
pass
|
|
656
729
|
for root in roots:
|
|
657
|
-
candidate = root / ".research" / "scratchpads"
|
|
658
|
-
if candidate.
|
|
659
|
-
|
|
660
|
-
return hit
|
|
730
|
+
candidate = root / ".research" / "scratchpads" / scope_id / filename
|
|
731
|
+
if candidate.exists():
|
|
732
|
+
return candidate
|
|
661
733
|
return None
|
|
662
734
|
|
|
663
735
|
def run_agent_alpha(self, scope: Dict[str, Any]):
|
|
@@ -822,7 +894,11 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
822
894
|
self.state_machine.get_scope_dir(scope_id) / "alpha_dossier.json"
|
|
823
895
|
)
|
|
824
896
|
dossier = self._load_agent_dossier(
|
|
825
|
-
dossier_path,
|
|
897
|
+
dossier_path,
|
|
898
|
+
scope_id,
|
|
899
|
+
"alpha",
|
|
900
|
+
transcript,
|
|
901
|
+
aliases=MODE_DOSSIER_ALIASES.get(self.mode, ()),
|
|
826
902
|
)
|
|
827
903
|
|
|
828
904
|
self.state_machine.record_agent_completion(scope_id, "alpha", dossier)
|
|
@@ -1017,7 +1093,11 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
1017
1093
|
self.state_machine.get_scope_dir(scope_id) / "beta_dossier.json"
|
|
1018
1094
|
)
|
|
1019
1095
|
dossier = self._load_agent_dossier(
|
|
1020
|
-
dossier_path,
|
|
1096
|
+
dossier_path,
|
|
1097
|
+
scope_id,
|
|
1098
|
+
"beta",
|
|
1099
|
+
transcript,
|
|
1100
|
+
aliases=MODE_DOSSIER_ALIASES.get(self.mode, ()),
|
|
1021
1101
|
)
|
|
1022
1102
|
|
|
1023
1103
|
self.state_machine.record_agent_completion(scope_id, "beta", dossier)
|
|
@@ -1108,7 +1188,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
1108
1188
|
|
|
1109
1189
|
def _all_scopes_complete(self, manifest: Dict[str, Any]) -> bool:
|
|
1110
1190
|
return all(
|
|
1111
|
-
self.state_machine.
|
|
1191
|
+
self.state_machine.reconcile_scope_status(s["scope_id"]).get("status")
|
|
1112
1192
|
== ScopeStatus.COMPLETE.value
|
|
1113
1193
|
for s in manifest["scopes"]
|
|
1114
1194
|
)
|
package/runner/state_machine.py
CHANGED
|
@@ -251,32 +251,50 @@ class ResearchStateMachine:
|
|
|
251
251
|
|
|
252
252
|
if agent_type.lower() in ["alpha", "thesis", "proponent"]:
|
|
253
253
|
filename = "alpha_dossier.json"
|
|
254
|
-
is_alpha = True
|
|
255
254
|
elif agent_type.lower() in ["beta", "antithesis", "adversary", "red_team"]:
|
|
256
255
|
filename = "beta_dossier.json"
|
|
257
|
-
is_alpha = False
|
|
258
256
|
else:
|
|
259
257
|
raise ValueError(f"Unknown agent type: {agent_type}")
|
|
260
258
|
|
|
261
259
|
_atomic_write_json(scope_dir / filename, dossier_data)
|
|
260
|
+
self.reconcile_scope_status(scope_id)
|
|
262
261
|
|
|
263
|
-
|
|
264
|
-
|
|
262
|
+
def _artifacts_complete(self, scope_id: str) -> tuple:
|
|
263
|
+
"""(alpha, beta) dossier presence, derived from disk — never from stored flags."""
|
|
264
|
+
scope_dir = self.get_scope_dir(scope_id)
|
|
265
|
+
return (
|
|
266
|
+
(scope_dir / "alpha_dossier.json").exists(),
|
|
267
|
+
(scope_dir / "beta_dossier.json").exists(),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def reconcile_scope_status(self, scope_id: str) -> Dict[str, Any]:
|
|
271
|
+
"""Recompute completion flags + status from dossier artifacts on disk.
|
|
272
|
+
|
|
273
|
+
The scope manifest is runner-owned, but agents can write to the workspace
|
|
274
|
+
and have forged `beta_completed: true` / `DOSSIERS_READY` without emitting
|
|
275
|
+
the dossier. Deriving from files means stored booleans can never disagree
|
|
276
|
+
with reality.
|
|
277
|
+
"""
|
|
278
|
+
alpha, beta = self._artifacts_complete(scope_id)
|
|
265
279
|
with self._lock, _file_lock(self._lock_file):
|
|
266
280
|
sm = self._read_scope_unlocked(scope_id)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
sm["
|
|
271
|
-
|
|
272
|
-
if sm.get("alpha_completed") and sm.get("beta_completed"):
|
|
281
|
+
sm["alpha_completed"] = alpha
|
|
282
|
+
sm["beta_completed"] = beta
|
|
283
|
+
if sm.get("audit_completed"):
|
|
284
|
+
sm["status"] = ScopeStatus.COMPLETE.value
|
|
285
|
+
elif alpha and beta:
|
|
273
286
|
sm["status"] = ScopeStatus.DOSSIERS_READY.value
|
|
274
|
-
elif
|
|
287
|
+
elif alpha:
|
|
275
288
|
sm["status"] = ScopeStatus.ALPHA_COMPLETE.value
|
|
276
|
-
elif
|
|
289
|
+
elif beta:
|
|
277
290
|
sm["status"] = ScopeStatus.BETA_COMPLETE.value
|
|
278
|
-
|
|
291
|
+
elif sm.get("status") not in (
|
|
292
|
+
ScopeStatus.RUNNING_PARALLEL.value,
|
|
293
|
+
ScopeStatus.AUDITING.value,
|
|
294
|
+
):
|
|
295
|
+
sm["status"] = ScopeStatus.PENDING.value
|
|
279
296
|
self._write_scope_unlocked(scope_id, sm)
|
|
297
|
+
return sm
|
|
280
298
|
|
|
281
299
|
def get_ready_scopes(self) -> List[Dict[str, Any]]:
|
|
282
300
|
"""Return scopes whose dependencies are completed and status is PENDING."""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dossier-contract + derived-status regressions.
|
|
3
|
+
|
|
4
|
+
Reproduced live (#1 retest on 0.7.10, brainstorm mode): the loader required
|
|
5
|
+
`beta_dossier.json`, the shared brainstorm prompt only ever named the
|
|
6
|
+
alpha-compatible dossier, so Beta emitted `brainstorm_dossier.json` and the run
|
|
7
|
+
aborted — while the scope manifest (written by the agent) claimed
|
|
8
|
+
`DOSSIERS_READY`. These tests pin both halves: an explicit role contract plus a
|
|
9
|
+
tolerant loader, and status derived from artifacts rather than stored booleans.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import unittest
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
21
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
22
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
23
|
+
|
|
24
|
+
from runner.research_swarm import SwarmRunner # noqa: E402
|
|
25
|
+
from runner.state_machine import ResearchStateMachine, ScopeStatus # noqa: E402
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestRoleOutputContract(unittest.TestCase):
|
|
34
|
+
def _runner(self, base: Path, mode: str = "brainstorm") -> SwarmRunner:
|
|
35
|
+
return SwarmRunner(base_dir=base, mock_mode=True, mode=mode)
|
|
36
|
+
|
|
37
|
+
def test_prompt_names_role_dossier_and_bans_manifest(self):
|
|
38
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
39
|
+
base = Path(tmp) / ".research"
|
|
40
|
+
runner = self._runner(base)
|
|
41
|
+
scope = {"scope_id": "s1", "title": "t", "objective": "o"}
|
|
42
|
+
|
|
43
|
+
alpha = runner._scope_prompt("Alpha", scope, "s1")
|
|
44
|
+
beta = runner._scope_prompt("Beta", scope, "s1")
|
|
45
|
+
|
|
46
|
+
self.assertIn(
|
|
47
|
+
str(runner.state_machine.get_scope_dir("s1") / "alpha_dossier.json"),
|
|
48
|
+
alpha,
|
|
49
|
+
)
|
|
50
|
+
self.assertIn(
|
|
51
|
+
str(runner.state_machine.get_scope_dir("s1") / "beta_dossier.json"),
|
|
52
|
+
beta,
|
|
53
|
+
)
|
|
54
|
+
self.assertNotIn("beta_dossier.json", alpha)
|
|
55
|
+
for text in (alpha, beta):
|
|
56
|
+
self.assertIn("manifest.json", text)
|
|
57
|
+
self.assertIn("runner-owned", text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestTolerantLoader(unittest.TestCase):
|
|
61
|
+
def test_alias_is_loaded_and_normalized(self):
|
|
62
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
63
|
+
base = Path(tmp) / ".research"
|
|
64
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="brainstorm")
|
|
65
|
+
scope_dir = runner.state_machine.get_scope_dir("s1")
|
|
66
|
+
_write_json(
|
|
67
|
+
scope_dir / "brainstorm_dossier.json", {"scope_id": "s1", "claims": []}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
data = runner._load_agent_dossier(
|
|
71
|
+
scope_dir / "beta_dossier.json",
|
|
72
|
+
"s1",
|
|
73
|
+
"beta",
|
|
74
|
+
None,
|
|
75
|
+
aliases=("brainstorm_dossier.json",),
|
|
76
|
+
)
|
|
77
|
+
self.assertEqual(data["scope_id"], "s1")
|
|
78
|
+
self.assertEqual(data["renamed_from"], "brainstorm_dossier.json")
|
|
79
|
+
self.assertTrue((scope_dir / "beta_dossier.json").exists())
|
|
80
|
+
|
|
81
|
+
def test_manifest_outputs_are_consulted(self):
|
|
82
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
83
|
+
base = Path(tmp) / ".research"
|
|
84
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="brainstorm")
|
|
85
|
+
scope_dir = runner.state_machine.get_scope_dir("s1")
|
|
86
|
+
_write_json(scope_dir / "weird_name.json", {"scope_id": "s1", "claims": []})
|
|
87
|
+
_write_json(
|
|
88
|
+
scope_dir / "manifest.json",
|
|
89
|
+
{"scope_id": "s1", "outputs": ["weird_name.json"]},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
data = runner._load_agent_dossier(
|
|
93
|
+
scope_dir / "beta_dossier.json", "s1", "beta", None
|
|
94
|
+
)
|
|
95
|
+
self.assertEqual(data["scope_id"], "s1")
|
|
96
|
+
self.assertTrue((scope_dir / "beta_dossier.json").exists())
|
|
97
|
+
|
|
98
|
+
def test_non_dossier_json_is_ignored(self):
|
|
99
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
100
|
+
base = Path(tmp) / ".research"
|
|
101
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="brainstorm")
|
|
102
|
+
scope_dir = runner.state_machine.get_scope_dir("s1")
|
|
103
|
+
# domain_model.json has no scope_id and must never be taken as a dossier.
|
|
104
|
+
_write_json(scope_dir / "manifest.json", {"outputs": ["domain_model.json"]})
|
|
105
|
+
_write_json(scope_dir / "domain_model.json", {"entities": ["a"]})
|
|
106
|
+
|
|
107
|
+
with self.assertRaises(FileNotFoundError):
|
|
108
|
+
runner._load_agent_dossier(
|
|
109
|
+
scope_dir / "beta_dossier.json", "s1", "beta", None
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestDerivedStatus(unittest.TestCase):
|
|
114
|
+
def test_forged_manifest_cannot_report_ready(self):
|
|
115
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
116
|
+
base = Path(tmp) / ".research"
|
|
117
|
+
sm = ResearchStateMachine(base_dir=base)
|
|
118
|
+
sm.init_session("x")
|
|
119
|
+
sm.set_scopes([{"scope_id": "s1", "title": "t", "objective": "o"}])
|
|
120
|
+
scope_dir = sm.get_scope_dir("s1")
|
|
121
|
+
|
|
122
|
+
# Agent pretends both dossiers exist.
|
|
123
|
+
_write_json(
|
|
124
|
+
scope_dir / "manifest.json",
|
|
125
|
+
{
|
|
126
|
+
"scope_id": "s1",
|
|
127
|
+
"status": ScopeStatus.DOSSIERS_READY.value,
|
|
128
|
+
"alpha_completed": True,
|
|
129
|
+
"beta_completed": True,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
sm.reconcile_scope_status("s1")
|
|
133
|
+
sm_after = sm.load_scope_manifest("s1")
|
|
134
|
+
self.assertFalse(sm_after["alpha_completed"])
|
|
135
|
+
self.assertFalse(sm_after["beta_completed"])
|
|
136
|
+
self.assertNotEqual(sm_after["status"], ScopeStatus.DOSSIERS_READY.value)
|
|
137
|
+
|
|
138
|
+
def test_status_derives_from_artifacts(self):
|
|
139
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
140
|
+
base = Path(tmp) / ".research"
|
|
141
|
+
sm = ResearchStateMachine(base_dir=base)
|
|
142
|
+
sm.init_session("x")
|
|
143
|
+
sm.set_scopes([{"scope_id": "s1", "title": "t", "objective": "o"}])
|
|
144
|
+
scope_dir = sm.get_scope_dir("s1")
|
|
145
|
+
|
|
146
|
+
sm.record_agent_completion("s1", "alpha", {"scope_id": "s1", "claims": []})
|
|
147
|
+
self.assertEqual(
|
|
148
|
+
sm.load_scope_manifest("s1")["status"], ScopeStatus.ALPHA_COMPLETE.value
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
sm.record_agent_completion("s1", "beta", {"scope_id": "s1", "claims": []})
|
|
152
|
+
after = sm.load_scope_manifest("s1")
|
|
153
|
+
self.assertEqual(after["status"], ScopeStatus.DOSSIERS_READY.value)
|
|
154
|
+
self.assertTrue(after["alpha_completed"] and after["beta_completed"])
|
|
155
|
+
|
|
156
|
+
# audit completion survives reconciliation
|
|
157
|
+
after["audit_completed"] = True
|
|
158
|
+
sm.save_scope_manifest("s1", after)
|
|
159
|
+
self.assertEqual(
|
|
160
|
+
sm.reconcile_scope_status("s1")["status"], ScopeStatus.COMPLETE.value
|
|
161
|
+
)
|
|
162
|
+
self.assertTrue(scope_dir.exists())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestScopedAlternateScan(unittest.TestCase):
|
|
166
|
+
def test_only_same_scope_is_reported(self):
|
|
167
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
168
|
+
project = Path(tmp)
|
|
169
|
+
alt = project / "alt"
|
|
170
|
+
base = project / ".research"
|
|
171
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
|
|
172
|
+
|
|
173
|
+
# Different scope holds the same filename.
|
|
174
|
+
_write_json(
|
|
175
|
+
alt / ".research" / "scratchpads" / "scope_other" / "beta_dossier.json",
|
|
176
|
+
{"scope_id": "scope_other"},
|
|
177
|
+
)
|
|
178
|
+
with mock.patch.dict(os.environ, {"PWD": str(alt)}, clear=False):
|
|
179
|
+
self.assertIsNone(
|
|
180
|
+
runner._locate_dossier_elsewhere("scope_mine", "beta_dossier.json")
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Same scope in the alternate workspace is reported.
|
|
184
|
+
target = (
|
|
185
|
+
alt / ".research" / "scratchpads" / "scope_mine" / "beta_dossier.json"
|
|
186
|
+
)
|
|
187
|
+
_write_json(target, {"scope_id": "scope_mine"})
|
|
188
|
+
with mock.patch.dict(os.environ, {"PWD": str(alt)}, clear=False):
|
|
189
|
+
self.assertEqual(
|
|
190
|
+
runner._locate_dossier_elsewhere("scope_mine", "beta_dossier.json"),
|
|
191
|
+
target,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
unittest.main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|