@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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/prompts/agent_brainstormer.md +7 -4
  4. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  6. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  7. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  8. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  18. package/runner/research_swarm.py +113 -33
  19. package/runner/state_machine.py +31 -13
  20. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  21. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  22. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  23. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  24. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  25. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  26. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  27. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  28. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  41. package/runner/tests/test_dossier_contract.py +196 -0
  42. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  45. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  46. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  47. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  48. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  49. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "High-Integrity Dialectic Research Agent Harness for Claude Code enforcing empirical evidence over parametric hallucination.",
5
5
  "author": {
6
6
  "name": "Heretek AI",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.7.10",
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 `alpha_dossier.json`-compatible brainstorm dossier when running
85
- under `runner/research_swarm.py --mode brainstorm` (claims use tag
86
- `HYPOTHESIS` with `falsification` field; `VERIFIED` only for ingested
87
- workspace facts with `file://` pointers and verbatim snippets).
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).
@@ -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 the absolute scratchpad dir.
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
- scope id left the agent guessing which `.research` tree to write to.
319
- Naming the absolute directory removes the ambiguity even when the
320
- spawn cwd is overridden.
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"Save findings to the scratchpad directory "
326
- f"{self.state_machine.get_scope_dir(scope_id)} (scope {scope_id}); "
327
- f"write the dossier files there and nowhere else."
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, falling back to stdout parsing.
634
+ """Load an agent dossier from disk, tolerating mode-specific filenames.
580
635
 
581
- Headless agents sometimes answer in chat instead of writing the
582
- dossier file; without this fallback the whole scope dies on
583
- FileNotFoundError (observed live: 10+ minute runs, zero dossiers).
584
- Parsed stdout dossiers are tagged so the auditor treats them as
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
- if dossier_path.exists():
588
- with open(dossier_path, "r", encoding="utf-8") as f:
589
- return json.load(f)
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 actually is.
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
- elsewhere = self._locate_dossier_elsewhere(dossier_path.name)
619
- if elsewhere:
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: {elsewhere} — the spawned agent wrote "
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
- """Best-effort scan for the dossier in workspaces the agent might have used."""
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.is_dir():
659
- for hit in candidate.glob(f"*/{filename}"):
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, scope_id, "alpha", transcript
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, scope_id, "beta", transcript
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.load_scope_manifest(s["scope_id"]).get("status")
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
  )
@@ -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
- # Alpha and Beta complete concurrently in separate threads: hold the lock
264
- # across the read-modify-write so neither loses the other's flag.
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
- if is_alpha:
268
- sm["alpha_completed"] = True
269
- else:
270
- sm["beta_completed"] = True
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 sm.get("alpha_completed"):
287
+ elif alpha:
275
288
  sm["status"] = ScopeStatus.ALPHA_COMPLETE.value
276
- elif sm.get("beta_completed"):
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."""
@@ -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()