@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -2
- package/package.json +2 -1
- package/plugins/factory/skills/factory/scripts/factory.py +68 -14
- package/plugins/opencode/index.js +21 -0
- package/plugins/socratic-grilling/skills/grilling/SKILL.md +14 -1
- package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +88 -16
- 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/errors.py +51 -0
- package/runner/mcp_server.py +132 -8
- package/runner/preflight.py +157 -0
- package/runner/research_swarm.py +148 -4
- package/runner/schema_validate.py +93 -0
- package/runner/schemas.py +270 -0
- package/runner/state_machine.py +96 -10
- 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_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.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_grounding.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_contract_tooling.py +312 -0
- package/runner/tests/test_mcp_server.py +2 -0
- package/runner/tests/test_opencode_ux.py +4 -2
- package/schemas/alpha_dossier.schema.json +204 -0
- package/schemas/audit_report.schema.json +76 -0
- package/schemas/beta_dossier.schema.json +214 -0
- package/schemas/frontier.schema.json +61 -0
- package/schemas/manifest.schema.json +43 -0
- package/schemas/scope_manifest.schema.json +47 -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__/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/check_docs.py +66 -0
- package/scripts/gen_schemas.py +69 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/factory/scripts/factory.py +68 -14
- package/skills/grilling/SKILL.md +14 -1
- 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 +88 -16
- 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/runner/mcp_server.py
CHANGED
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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)
|
package/runner/research_swarm.py
CHANGED
|
@@ -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
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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)
|