@heretek-ai/epistemic-swarm 0.7.13 → 0.7.15

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 (84) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -2
  3. package/package.json +2 -1
  4. package/plugins/factory/skills/factory/scripts/factory.py +68 -14
  5. package/plugins/opencode/index.js +21 -0
  6. package/plugins/socratic-grilling/skills/grilling/SKILL.md +14 -1
  7. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +88 -16
  8. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  28. package/runner/errors.py +51 -0
  29. package/runner/mcp_server.py +132 -8
  30. package/runner/preflight.py +157 -0
  31. package/runner/research_swarm.py +148 -4
  32. package/runner/schema_validate.py +93 -0
  33. package/runner/schemas.py +270 -0
  34. package/runner/state_machine.py +96 -10
  35. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  59. package/runner/tests/test_contract_tooling.py +312 -0
  60. package/runner/tests/test_mcp_server.py +2 -0
  61. package/runner/tests/test_opencode_ux.py +4 -2
  62. package/schemas/alpha_dossier.schema.json +204 -0
  63. package/schemas/audit_report.schema.json +76 -0
  64. package/schemas/beta_dossier.schema.json +214 -0
  65. package/schemas/frontier.schema.json +61 -0
  66. package/schemas/manifest.schema.json +43 -0
  67. package/schemas/scope_manifest.schema.json +47 -0
  68. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  69. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  70. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  71. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  72. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  73. package/scripts/check_docs.py +66 -0
  74. package/scripts/gen_schemas.py +69 -0
  75. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  76. package/skills/factory/scripts/factory.py +68 -14
  77. package/skills/grilling/SKILL.md +14 -1
  78. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  79. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  80. package/skills/grilling/socratic_tree.py +88 -16
  81. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  82. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  83. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  84. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
@@ -150,6 +150,8 @@ def _run_swarm_mode(mode: Optional[str], args: Dict[str, Any]) -> str:
150
150
  depth=args.get("depth"),
151
151
  domain_pack=args.get("domain_pack"),
152
152
  agent_overrides=overrides,
153
+ resume=bool(args.get("resume")),
154
+ dry_run=bool(args.get("dry_run")),
153
155
  )
154
156
  buf = io.StringIO()
155
157
  with redirect_stdout(buf):
@@ -209,9 +211,9 @@ def _handle_factory(args: Dict[str, Any]) -> str:
209
211
  import subprocess
210
212
 
211
213
  command = str(args.get("command") or "").strip()
212
- if command not in ("init", "phase-add", "qa-record", "expansion", "stop"):
214
+ if command not in ("init", "phase-add", "qa-record", "expansion", "stop", "gate"):
213
215
  raise ValueError(
214
- "command must be one of: init, phase-add, qa-record, expansion, stop"
216
+ "command must be one of: init, phase-add, qa-record, expansion, stop, gate"
215
217
  )
216
218
  script = Path(PROJECT_ROOT) / "skills" / "factory" / "scripts" / "factory.py"
217
219
  if not script.is_file():
@@ -412,20 +414,95 @@ def _handle_reindex_claims(args: Dict[str, Any]) -> str:
412
414
  return _tool_text(reindex(base_dir))
413
415
 
414
416
 
417
+ def _handle_doctor(args: Dict[str, Any]) -> str:
418
+ """Preflight for the resolved workspace: backend, engine, write test, version."""
419
+ from runner.preflight import format_report, preflight
420
+ from skills.swarm_config.configure import load_config
421
+
422
+ base_dir = _resolve_base_dir(args)
423
+ cfg = load_config(str(base_dir))
424
+ report = preflight(
425
+ base_dir,
426
+ mode=args.get("mode") or cfg.get("mode") or "research",
427
+ config=cfg,
428
+ # Never spawn in a health check; report resolution only.
429
+ mock_mode=True,
430
+ probe=bool(args.get("probe", True)),
431
+ )
432
+ return _tool_text({"report": report, "text": format_report(report)})
433
+
434
+
435
+ def _handle_test(args: Dict[str, Any]) -> str:
436
+ """Run the packaged test suite in a subprocess (timeout-bounded)."""
437
+ import subprocess
438
+
439
+ timeout = float(os.environ.get("IUMBTEMS_TEST_TIMEOUT", "300"))
440
+ try:
441
+ result = subprocess.run(
442
+ [sys.executable, "-m", "unittest", "discover", "-s", "runner/tests"],
443
+ cwd=str(PROJECT_ROOT),
444
+ capture_output=True,
445
+ text=True,
446
+ timeout=timeout,
447
+ stdin=subprocess.DEVNULL,
448
+ )
449
+ except subprocess.TimeoutExpired:
450
+ return _tool_text({"status": "timeout", "timeout_s": timeout})
451
+ tail = "\n".join((result.stderr or "").strip().splitlines()[-12:])
452
+ passed = result.returncode == 0
453
+ return _tool_text(
454
+ {
455
+ "status": "pass" if passed else "fail",
456
+ "returncode": result.returncode,
457
+ "tail": tail,
458
+ }
459
+ )
460
+
461
+
415
462
  def _handle_socratic_frontier(args: Dict[str, Any]) -> str:
416
- """Advance or inspect the Socratic decision-tree frontier."""
417
- from skills.grilling.socratic_tree import DesignTree
463
+ """Inspect, add to, settle, or export the Socratic decision-tree frontier."""
464
+ from skills.grilling.socratic_tree import DecisionNode, DesignTree
418
465
 
419
466
  file_path = Path(
420
467
  args.get("file") or args.get("frontier_file") or ".research/frontier.json"
421
468
  )
469
+ action = str(args.get("action") or "").strip().lower()
470
+ node_spec = args.get("node") if isinstance(args.get("node"), dict) else {}
471
+
472
+ def _tree():
473
+ if file_path.exists():
474
+ return DesignTree.load_from_json(file_path)
475
+ return DesignTree(objective=args.get("objective", ""))
476
+
477
+ # Authoring: add a node (tool-drivable, previously hand-written JSON only).
478
+ if action == "add" or node_spec:
479
+ if not node_spec.get("id") or not node_spec.get("question"):
480
+ return _tool_text(
481
+ {"status": "error", "message": "'node' needs 'id' and 'question'"}
482
+ )
483
+ tree = _tree()
484
+ tree.add_node(
485
+ DecisionNode(
486
+ node_id=node_spec["id"],
487
+ title=node_spec.get("title") or node_spec["id"],
488
+ question=node_spec["question"],
489
+ recommended=node_spec.get("recommended", ""),
490
+ options=node_spec.get("options") or [],
491
+ prerequisites=node_spec.get("depends_on") or [],
492
+ )
493
+ )
494
+ tree.export_frontier_json(file_path)
495
+ return _tool_text(
496
+ {"status": "added", "node_id": node_spec["id"], "file": str(file_path)}
497
+ )
498
+
499
+ if action == "export":
500
+ _tree().export_frontier_json(file_path)
501
+ return _tool_text({"status": "exported", "file": str(file_path)})
422
502
 
423
503
  if args.get("settle"):
424
504
  node_id, answer = args["settle"][0], args["settle"][1]
425
- if file_path.exists():
426
- tree = DesignTree.load_from_json(file_path)
427
- else:
428
- tree = DesignTree(objective=args.get("objective", ""))
505
+ tree = _tree()
429
506
  tree.settle_node(node_id, answer)
430
507
  tree.export_frontier_json(file_path)
431
508
  return _tool_text(
@@ -545,6 +622,10 @@ def build_tools() -> List[ToolSpec]:
545
622
  "description": "Codebase target or audit focus",
546
623
  },
547
624
  "base_dir": {"type": "string"},
625
+ "frontier_file": {
626
+ "type": "string",
627
+ "description": "Settled frontier.json from /grilling",
628
+ },
548
629
  "mock_claude": {"type": "boolean"},
549
630
  "backend": {
550
631
  "type": "string",
@@ -566,6 +647,10 @@ def build_tools() -> List[ToolSpec]:
566
647
  "description": "Feature or library to scout",
567
648
  },
568
649
  "base_dir": {"type": "string"},
650
+ "frontier_file": {
651
+ "type": "string",
652
+ "description": "Settled frontier.json from /grilling",
653
+ },
569
654
  "mock_claude": {"type": "boolean"},
570
655
  "backend": {
571
656
  "type": "string",
@@ -587,6 +672,10 @@ def build_tools() -> List[ToolSpec]:
587
672
  "description": "Ambiguous prompt to ideate on",
588
673
  },
589
674
  "base_dir": {"type": "string"},
675
+ "frontier_file": {
676
+ "type": "string",
677
+ "description": "Settled frontier.json from /grilling",
678
+ },
590
679
  "mock_claude": {"type": "boolean"},
591
680
  "backend": {
592
681
  "type": "string",
@@ -618,6 +707,10 @@ def build_tools() -> List[ToolSpec]:
618
707
  },
619
708
  "depth": {"type": "integer", "description": "Dialectic depth 1-4"},
620
709
  "base_dir": {"type": "string"},
710
+ "frontier_file": {
711
+ "type": "string",
712
+ "description": "Settled frontier.json from /grilling",
713
+ },
621
714
  "mock_claude": {"type": "boolean"},
622
715
  "backend": {
623
716
  "type": "string",
@@ -801,6 +894,15 @@ def build_tools() -> List[ToolSpec]:
801
894
  "properties": {
802
895
  "file": {"type": "string", "description": "Path to frontier.json"},
803
896
  "objective": {"type": "string"},
897
+ "action": {
898
+ "type": "string",
899
+ "enum": ["inspect", "add", "settle", "export"],
900
+ "description": "inspect (default) | add a node | settle a node | export the file",
901
+ },
902
+ "node": {
903
+ "type": "object",
904
+ "description": "Node to add: {id, question, title?, recommended?, depends_on?[]}",
905
+ },
804
906
  "settle": {
805
907
  "type": "array",
806
908
  "items": {"type": "string"},
@@ -810,6 +912,28 @@ def build_tools() -> List[ToolSpec]:
810
912
  },
811
913
  handler=_handle_socratic_frontier,
812
914
  ),
915
+ ToolSpec(
916
+ name="iumbtems_doctor",
917
+ description="Preflight health check for the resolved workspace: plugin version, backend binary, search-engine reachability, and workspace writability. Run this first when anything looks wrong.",
918
+ input_schema={
919
+ "type": "object",
920
+ "properties": {
921
+ "base_dir": {"type": "string"},
922
+ "mode": {"type": "string"},
923
+ "probe": {
924
+ "type": "boolean",
925
+ "description": "Run a live 1-query search probe (default true)",
926
+ },
927
+ },
928
+ },
929
+ handler=_handle_doctor,
930
+ ),
931
+ ToolSpec(
932
+ name="iumbtems_test",
933
+ description="Run the packaged IUMBTEMS test suite (subprocess, timeout-bounded) and report pass/fail with the tail of the output.",
934
+ input_schema={"type": "object", "properties": {}},
935
+ handler=_handle_test,
936
+ ),
813
937
  ]
814
938
 
815
939
 
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """Preflight / doctor report for a run (Heretek-AI/IUMBTEMS#6 item 3.2).
3
+
4
+ The plugin knows things the agent currently has to rediscover: which workspace
5
+ resolves, which manifest filename is in play, which backend binary will actually
6
+ spawn, and whether the search engine works right now. This reports all of it in
7
+ one place, prints it at run start, records it in the manifest, and backs the
8
+ `iumbtems_doctor` MCP tool.
9
+
10
+ Everything here is best-effort and offline-safe: the search probe is
11
+ timeout-bounded and can be skipped, and no failure raises.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import shutil
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any, Dict, Optional
20
+
21
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
22
+
23
+ DEFAULT_PROBE_QUERY = "iumbtems preflight probe"
24
+
25
+
26
+ def plugin_version() -> Optional[str]:
27
+ try:
28
+ return json.loads(
29
+ (PROJECT_ROOT / "package.json").read_text(encoding="utf-8")
30
+ ).get("version")
31
+ except (OSError, ValueError):
32
+ return None
33
+
34
+
35
+ _plugin_version = plugin_version # backwards-compatible alias
36
+
37
+
38
+ def _backend_report(base_dir: Path, mock_mode: bool) -> Dict[str, Any]:
39
+ try:
40
+ from runner.research_swarm import (
41
+ SwarmRunner, # noqa: F401 (ensures module import side effects)
42
+ _default_backend_cmd,
43
+ _opencode_auto,
44
+ )
45
+
46
+ runner = SwarmRunner(base_dir=base_dir, mock_mode=mock_mode, mode="research")
47
+ backend, model = runner._resolve_agent_backend("alpha")
48
+ family = backend[0] if backend else "?"
49
+ resolved = shutil.which(family) or family
50
+ return {
51
+ "family": family,
52
+ "argv": backend,
53
+ "model": model,
54
+ "binary": resolved,
55
+ "binary_found": bool(shutil.which(family)),
56
+ "opencode_auto": _opencode_auto(runner.config),
57
+ "default_auto": _default_backend_cmd("auto"),
58
+ }
59
+ except Exception as exc: # noqa: BLE001 - doctor must never raise
60
+ return {"error": str(exc)}
61
+
62
+
63
+ def _engine_probe(engine: str, probe: bool, timeout: float) -> Dict[str, Any]:
64
+ if not probe:
65
+ return {"skipped": "probe disabled"}
66
+ if engine != "duckduckgo":
67
+ return {"skipped": f"no zero-key probe for engine '{engine}'"}
68
+ try:
69
+ sys.path.insert(
70
+ 0, str(PROJECT_ROOT / "skills" / "epistemic_search" / "scripts")
71
+ )
72
+ import search # noqa: PLC0415
73
+
74
+ results = search.search_duckduckgo(DEFAULT_PROBE_QUERY, max_results=3)
75
+ return {"ok": bool(results), "results": len(results)}
76
+ except Exception as exc: # noqa: BLE001 - network/offline is not an error
77
+ return {"ok": False, "error": str(exc)}
78
+
79
+
80
+ def _scratchpad_write_test(base_dir: Path) -> Dict[str, Any]:
81
+ try:
82
+ base_dir.mkdir(parents=True, exist_ok=True)
83
+ probe = base_dir / ".preflight-write-test"
84
+ probe.write_text("ok", encoding="utf-8")
85
+ probe.unlink()
86
+ return {"ok": True, "path": str(base_dir)}
87
+ except OSError as exc:
88
+ return {"ok": False, "path": str(base_dir), "error": str(exc)}
89
+
90
+
91
+ def preflight(
92
+ base_dir: Path,
93
+ mode: str = "research",
94
+ config: Optional[Dict[str, Any]] = None,
95
+ mock_mode: bool = False,
96
+ probe: bool = True,
97
+ timeout: float = 5.0,
98
+ ) -> Dict[str, Any]:
99
+ """Assemble the preflight report. Never raises."""
100
+ config = config or {}
101
+ engine = str(config.get("search_engine") or "duckduckgo")
102
+ report: Dict[str, Any] = {
103
+ "plugin_version": _plugin_version(),
104
+ "python": sys.version.split()[0],
105
+ "node": shutil.which("node") or None,
106
+ "mode": mode,
107
+ "workspace": str(base_dir),
108
+ "manifest": None,
109
+ "project_dir": os.environ.get("IUMBTEMS_PROJECT_DIR") or None,
110
+ "cwd": os.getcwd(),
111
+ "pwd": os.environ.get("PWD") or None,
112
+ "engine": engine,
113
+ "warnings": [],
114
+ }
115
+ try:
116
+ from runner.state_machine import manifest_name_for_mode
117
+
118
+ report["manifest"] = manifest_name_for_mode(mode)
119
+ except Exception: # noqa: BLE001
120
+ pass
121
+
122
+ report["backend"] = _backend_report(base_dir, mock_mode)
123
+ report["engine_probe"] = _engine_probe(engine, probe, timeout)
124
+ report["workspace_write"] = _scratchpad_write_test(base_dir)
125
+
126
+ if report["backend"].get("binary_found") is False and not mock_mode:
127
+ report["warnings"].append(
128
+ f"backend binary '{report['backend'].get('family')}' not found on PATH"
129
+ )
130
+ if report["engine_probe"].get("ok") is False:
131
+ report["warnings"].append("search engine probe returned no results")
132
+ if report["workspace_write"].get("ok") is False:
133
+ report["warnings"].append("workspace is not writable")
134
+ return report
135
+
136
+
137
+ def format_report(report: Dict[str, Any]) -> str:
138
+ backend = report.get("backend", {})
139
+ probe = report.get("engine_probe", {})
140
+ probe_txt = (
141
+ f"{probe.get('results')} results"
142
+ if probe.get("ok")
143
+ else probe.get("skipped") or f"FAILED ({probe.get('error', '?')})"
144
+ )
145
+ lines = [
146
+ f"iumbtems preflight: v{report.get('plugin_version')} | mode={report.get('mode')}",
147
+ f" workspace : {report.get('workspace')} | manifest: {report.get('manifest')}",
148
+ f" spawn cwd : {report.get('cwd')} | PWD: {report.get('pwd')}",
149
+ f" backend : {backend.get('family')} ({backend.get('binary')})"
150
+ f"{'' if backend.get('binary_found') else ' ⚠️ NOT FOUND'}"
151
+ f"{' | --auto' if backend.get('opencode_auto') else ''}",
152
+ f" engine : {report.get('engine')} -> {probe_txt}",
153
+ f" write test: {'ok' if report.get('workspace_write', {}).get('ok') else 'FAILED'}",
154
+ ]
155
+ for warning in report.get("warnings", []):
156
+ lines.append(f" ⚠️ {warning}")
157
+ return "\n".join(lines)
@@ -28,6 +28,7 @@ from runner.state_machine import (
28
28
  _atomic_write_json,
29
29
  )
30
30
  from runner.auditor_engine import EpistemicAuditorEngine
31
+ from runner.errors import DossierNotFound
31
32
  from runner.retrieval_log import current_offset as _retrieval_offset
32
33
  from runner.retrieval_log import summarize as _retrieval_summary
33
34
  from skills.research_cache.hasher import SourceHasher
@@ -242,8 +243,12 @@ class SwarmRunner:
242
243
  agent_overrides: Optional[Dict[str, Dict[str, Any]]] = None,
243
244
  allocation: Optional[str] = None,
244
245
  domain_pack: Optional[str] = None,
246
+ resume: bool = False,
247
+ dry_run: bool = False,
245
248
  ):
246
249
  self.base_dir = base_dir or Path(".research")
250
+ self.resume = resume
251
+ self.dry_run = dry_run
247
252
  # Project root = the directory containing the evidence dir. Agents are
248
253
  # spawned here so the relative `.research/...` paths named by the
249
254
  # canonical prompts resolve to the RUNNER's evidence tree. Spawning in
@@ -597,6 +602,22 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
597
602
  """Extract a dossier dict from free-form agent stdout (fence-aware)."""
598
603
  return SwarmRunner._parse_json_block(text, require_key="scope_id")
599
604
 
605
+ @staticmethod
606
+ def _warn_schema_violations(dossier: Dict[str, Any], role: str) -> None:
607
+ """Advisory contract check — warn with the offending key, never abort (#6 2.1)."""
608
+ try:
609
+ from runner.schema_validate import check_dossier
610
+
611
+ problems = check_dossier(dossier, role)
612
+ except Exception: # noqa: BLE001 - validation must never break a run
613
+ return
614
+ for problem in problems[:5]:
615
+ sys.stderr.write(f"[swarm] schema: {problem}\n")
616
+ if len(problems) > 5:
617
+ sys.stderr.write(
618
+ f"[swarm] schema: … {len(problems) - 5} more violation(s)\n"
619
+ )
620
+
600
621
  @staticmethod
601
622
  def _load_dossier_file(
602
623
  path: Path, expected_scope: Optional[str] = None
@@ -682,6 +703,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
682
703
  f" [{role}] ⚠️ Dossier found as {candidate.name}; "
683
704
  f"normalized to {dossier_path.name}."
684
705
  )
706
+ self._warn_schema_violations(data, role)
685
707
  return data
686
708
  recovered = self._parse_dossier_json(transcript or "")
687
709
  if recovered is not None:
@@ -690,8 +712,25 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
690
712
  _atomic_write_json(dossier_path, recovered)
691
713
  print(f" [{role}] ⚠️ Dossier file missing; recovered from stdout.")
692
714
  return recovered
693
- raise FileNotFoundError(
694
- self._dossier_missing_message(dossier_path, scope_id, role, aliases)
715
+ message = self._dossier_missing_message(dossier_path, scope_id, role, aliases)
716
+ elsewhere = None
717
+ for name in [dossier_path.name, *aliases]:
718
+ found = self._locate_dossier_elsewhere(scope_id, name)
719
+ if found:
720
+ elsewhere = found
721
+ break
722
+ raise DossierNotFound(
723
+ code="DOSSIER_NOT_FOUND",
724
+ message=message,
725
+ path_searched=str(dossier_path),
726
+ path_written=str(elsewhere) if elsewhere else None,
727
+ workspace=str(self.base_dir),
728
+ spawn_cwd=_agent_cwd(self.project_root),
729
+ suggested_fix=(
730
+ "The agent likely wrote to a different workspace than the runner "
731
+ "reads. Re-run on the current plugin, or point IUMBTEMS_AGENT_CWD / "
732
+ "IUMBTEMS_PROJECT_DIR at the runner's workspace."
733
+ ),
695
734
  )
696
735
 
697
736
  def _dossier_missing_message(
@@ -1219,6 +1258,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
1219
1258
  else:
1220
1259
  for scope in ready_scopes:
1221
1260
  self.execute_scope_dialectic(scope)
1261
+ self._write_progress()
1222
1262
 
1223
1263
  def _all_scopes_complete(self, manifest: Dict[str, Any]) -> bool:
1224
1264
  return all(
@@ -1247,10 +1287,40 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
1247
1287
  # Retrieval telemetry baseline: count only events from this run.
1248
1288
  self._retrieval_offset = _retrieval_offset(self.base_dir)
1249
1289
  print(f" {self._retrieval_banner()}")
1290
+
1291
+ # Preflight: workspace, backend binary, engine reachability, write test.
1292
+ from runner.preflight import format_report, preflight
1293
+
1294
+ self._preflight = preflight(
1295
+ self.base_dir,
1296
+ mode=self.mode,
1297
+ config=self.config,
1298
+ mock_mode=self.mock_mode,
1299
+ probe=os.environ.get("IUMBTEMS_PREFLIGHT_PROBE", "1").strip().lower()
1300
+ not in ("0", "false", "no", "off"),
1301
+ )
1302
+ print(format_report(self._preflight))
1250
1303
  print("=" * 70)
1304
+ self.state_machine.record_preflight(
1305
+ self._preflight, backend=self._preflight.get("backend", {}).get("family")
1306
+ )
1307
+
1308
+ if self.dry_run:
1309
+ print("\n🧪 [Dry Run] configuration validated; no agents spawned.")
1310
+ return
1251
1311
 
1252
- # 1. Orchestrate
1253
- self.orchestrate_objective(objective, frontier_file)
1312
+ # 1. Orchestrate (skip when resuming a session that already has scopes)
1313
+ resumed = False
1314
+ if self.resume:
1315
+ try:
1316
+ existing = self.state_machine.load_global_manifest().get("scopes") or []
1317
+ except FileNotFoundError:
1318
+ existing = []
1319
+ resumed = bool(existing)
1320
+ if resumed:
1321
+ print(f" ↻ resume: {len(existing)} scope(s) already recorded")
1322
+ if not resumed:
1323
+ self.orchestrate_objective(objective, frontier_file)
1254
1324
 
1255
1325
  # 2. Execute scopes according to DAG
1256
1326
  if not self._run_scope_batches():
@@ -1266,8 +1336,70 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
1266
1336
 
1267
1337
  duration = (datetime.now(timezone.utc) - start_time).total_seconds()
1268
1338
  print(f" {self._retrieval_banner()}")
1339
+ self._warn_version_drift()
1340
+ self._report_stdout_recovery()
1269
1341
  print(f"\n🎉 Swarm run completed in {duration:.1f}s. Report: {report_path}")
1270
1342
 
1343
+ def _write_progress(self) -> None:
1344
+ """Per-scope status snapshot so a long run is observable while it runs."""
1345
+ try:
1346
+ manifest = self.state_machine.load_global_manifest()
1347
+ rows = [
1348
+ {
1349
+ "scope_id": scope["scope_id"],
1350
+ "status": self.state_machine.reconcile_scope_status(
1351
+ scope["scope_id"]
1352
+ ).get("status"),
1353
+ }
1354
+ for scope in manifest.get("scopes", [])
1355
+ ]
1356
+ (self.base_dir / "progress.json").write_text(
1357
+ json.dumps(rows, indent=2), encoding="utf-8"
1358
+ )
1359
+ except Exception: # noqa: BLE001 - progress must not fail a run
1360
+ return
1361
+
1362
+ def _report_stdout_recovery(self) -> None:
1363
+ """Surface dossiers salvaged from stdout — the file-write path failed (#6 2.9)."""
1364
+ try:
1365
+ manifest = self.state_machine.load_global_manifest()
1366
+ except Exception: # noqa: BLE001
1367
+ return
1368
+ recovered = []
1369
+ for scope in manifest.get("scopes", []):
1370
+ scope_dir = self.state_machine.get_scope_dir(scope["scope_id"])
1371
+ for name in ("alpha_dossier.json", "beta_dossier.json"):
1372
+ path = scope_dir / name
1373
+ if not path.exists():
1374
+ continue
1375
+ try:
1376
+ if json.loads(path.read_text(encoding="utf-8")).get(
1377
+ "recovered_from_stdout"
1378
+ ):
1379
+ recovered.append(f"{scope['scope_id']}/{name}")
1380
+ except (OSError, ValueError):
1381
+ continue
1382
+ if recovered:
1383
+ print(
1384
+ f" ⚠️ {len(recovered)} dossier(s) recovered from stdout "
1385
+ f"(agent file write failed): {', '.join(recovered[:4])}"
1386
+ )
1387
+
1388
+ def _warn_version_drift(self) -> None:
1389
+ """Warn when the installed plugin version changed during the run (#6 2.7)."""
1390
+ try:
1391
+ from runner.preflight import plugin_version
1392
+
1393
+ current = plugin_version()
1394
+ recorded = self.state_machine.load_global_manifest().get("plugin_version")
1395
+ except Exception: # noqa: BLE001
1396
+ return
1397
+ if recorded and current and recorded != current:
1398
+ print(
1399
+ f" ⚠️ plugin version changed mid-run: {recorded} -> {current}; "
1400
+ "artifacts may span versions"
1401
+ )
1402
+
1271
1403
  def _write_orphan_report(self) -> None:
1272
1404
  """Record scope dirs absent from every manifest (mark-only, never delete)."""
1273
1405
  try:
@@ -1500,6 +1632,16 @@ def main():
1500
1632
  default=None,
1501
1633
  help="Agent runtime backend for both roles (default: auto = host-native; explicit beats env/config)",
1502
1634
  )
1635
+ parser.add_argument(
1636
+ "--resume",
1637
+ action="store_true",
1638
+ help="Continue an existing session instead of re-orchestrating",
1639
+ )
1640
+ parser.add_argument(
1641
+ "--dry-run",
1642
+ action="store_true",
1643
+ help="Validate config/backends/engine and exit without spawning agents",
1644
+ )
1503
1645
 
1504
1646
  args = parser.parse_args()
1505
1647
  frontier_path = Path(args.frontier) if args.frontier else None
@@ -1525,6 +1667,8 @@ def main():
1525
1667
  agent_overrides=agent_overrides or None,
1526
1668
  allocation=args.allocation,
1527
1669
  domain_pack=args.domain_pack,
1670
+ resume=getattr(args, "resume", False),
1671
+ dry_run=getattr(args, "dry_run", False),
1528
1672
  )
1529
1673
  runner.run_swarm(args.objective, frontier_file=frontier_path)
1530
1674
 
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Dependency-free JSON Schema subset validator.
3
+
4
+ The package must not pull in `jsonschema` (no auto-installing deps), so this
5
+ supports the subset `runner/schemas.py` actually uses: `type` (incl. unions),
6
+ `required`, `properties`, `items`, `enum`. Unknown keys are allowed on purpose —
7
+ contracts evolve and a new optional field must not fail a run.
8
+
9
+ Validation is advisory: `_load_agent_dossier` warns and keeps going (#6 item 2.1).
10
+ """
11
+
12
+ from typing import Any, Dict, List
13
+
14
+ _TYPE_MAP = {
15
+ "object": dict,
16
+ "array": list,
17
+ "string": str,
18
+ "boolean": bool,
19
+ "number": (int, float),
20
+ "integer": int,
21
+ "null": type(None),
22
+ }
23
+
24
+
25
+ def _type_matches(value: Any, expected: str) -> bool:
26
+ if expected == "integer":
27
+ return isinstance(value, int) and not isinstance(value, bool)
28
+ if expected == "number":
29
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
30
+ if expected == "boolean":
31
+ return isinstance(value, bool)
32
+ py = _TYPE_MAP.get(expected)
33
+ return py is not None and isinstance(value, py)
34
+
35
+
36
+ def validate(data: Any, schema: Dict[str, Any], path: str = "") -> List[str]:
37
+ """Return human-readable violations (dotted paths). Empty list == valid."""
38
+ if not isinstance(schema, dict):
39
+ return []
40
+ problems: List[str] = []
41
+
42
+ expected = schema.get("type")
43
+ if expected is not None:
44
+ types = expected if isinstance(expected, list) else [expected]
45
+ if not any(_type_matches(data, t) for t in types):
46
+ return [
47
+ f"{path or '<root>'}: expected {'|'.join(types)}, got {type(data).__name__}"
48
+ ]
49
+
50
+ if isinstance(data, dict):
51
+ for key in schema.get("required", []):
52
+ if key not in data:
53
+ problems.append(f"{path or '<root>'}: missing required key '{key}'")
54
+ for key, subschema in (schema.get("properties") or {}).items():
55
+ if key in data:
56
+ child = f"{path}.{key}" if path else key
57
+ problems.extend(validate(data[key], subschema, child))
58
+ additional = schema.get("additionalProperties")
59
+ if isinstance(additional, dict):
60
+ known = set((schema.get("properties") or {}).keys())
61
+ for key, value in data.items():
62
+ if key not in known:
63
+ child = f"{path}.{key}" if path else key
64
+ problems.extend(validate(value, additional, child))
65
+ enum = schema.get("enum")
66
+ if enum is not None and data not in enum:
67
+ problems.append(f"{path or '<root>'}: value {data!r} not in enum {enum}")
68
+
69
+ if isinstance(data, list) and isinstance(schema.get("items"), dict):
70
+ for i, item in enumerate(data):
71
+ problems.extend(validate(item, schema["items"], f"{path}[{i}]"))
72
+
73
+ if not isinstance(data, (dict, list)):
74
+ enum = schema.get("enum")
75
+ if enum is not None and data not in enum:
76
+ problems.append(f"{path or '<root>'}: value {data!r} not in enum {enum}")
77
+
78
+ return problems
79
+
80
+
81
+ def validate_named(name: str, data: Any) -> List[str]:
82
+ """Validate against a named schema from runner/schemas.py."""
83
+ from runner.schemas import SCHEMAS
84
+
85
+ schema = SCHEMAS.get(name)
86
+ if schema is None:
87
+ return []
88
+ return [f"{name}: {p}" for p in validate(data, schema)]
89
+
90
+
91
+ def check_dossier(dossier: Any, role: str) -> List[str]:
92
+ name = "alpha_dossier" if role.lower().startswith("alpha") else "beta_dossier"
93
+ return validate_named(name, dossier)