@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.7.13",
3
+ "version": "0.7.15",
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/README.md CHANGED
@@ -199,11 +199,13 @@ iumbtems adapters
199
199
  # Other utility commands
200
200
  iumbtems install # install skills & MCP servers into ~/.claude/
201
201
  iumbtems marketplace # print the Claude Code marketplace catalog
202
- iumbtems doctor # check environment requirements
202
+ iumbtems doctor # environment + preflight health check
203
203
  iumbtems test # run the automated test suite
204
204
  iumbtems help # full usage text
205
205
  ```
206
206
 
207
+ Run-shaping flags: `--resume` (continue a session instead of re-orchestrating) and `--dry-run` (validate config/backends/engine without spawning agents). Frontier authoring is fully tool-driven: `socratic_tree.py --add-node/--settle/--export`.
208
+
207
209
  Common options: `--mock-claude` (synthetic responses, zero API cost), `--frontier <file>` (settled `frontier.json`), `--mode <research|audit|scout|hybrid|brainstorm|darkharvest>`, `--engine <duckduckgo|brave|firecrawl|searxng>`, `--depth <1-4>`, `--backend <auto|claude|opencode>`, `--dir <path>`.
208
210
 
209
211
  The `epistemic-swarm` binary is an alias of `iumbtems`.
@@ -231,6 +233,8 @@ The canonical MCP server (`python3 runner/mcp_server.py`, stdio) exposes these t
231
233
  | `iumbtems_report_retraction` | Record a RETRACTED/REVISED event for a cached source; dependent `VERIFIED` claims degrade to STALE/SUSPECT on the next audit. |
232
234
  | `iumbtems_check_staleness` | One claim-degradation pass: join claims against retraction events, write the status ledger, queue re-runs for degraded scopes. Never mutates dossiers. |
233
235
  | `iumbtems_set_domain_pack` | Activate a Regulated Domain Pack (`biopharma` / `quant` / `legal` epistemic constitution) for subsequent audits. |
236
+ | `iumbtems_doctor` | Preflight health check: plugin version, backend + resolved binary, search-engine live probe, workspace write test. Run first when something looks wrong. |
237
+ | `iumbtems_test` | Run the packaged test suite in a subprocess and report pass/fail with the tail. |
234
238
 
235
239
  ---
236
240
 
@@ -332,7 +336,7 @@ python3 scripts/build_adapters.py --check # CI gate: fails on drift
332
336
  python3 -m unittest discover -s runner/tests
333
337
  ```
334
338
 
335
- `skills/research-cache` is a symlink to `skills/research_cache`, kept so the legacy hyphenated path resolves in a checkout (the Claude Code overlay installer creates the same alias under `~/.claude/skills/`).
339
+ The legacy hyphenated skill name is served by an alias the overlay installer creates under the user's `~/.claude/skills/` directory. It is deliberately **not** committed as an in-repo symlink: `claude plugin validate --strict` treats a symlink entry as a warning (→ error), which fails marketplace and eval validation.
336
340
 
337
341
  ---
338
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.7.13",
3
+ "version": "0.7.15",
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": {
@@ -118,6 +118,7 @@
118
118
  "bin",
119
119
  "prompts",
120
120
  "skills",
121
+ "schemas",
121
122
  "hooks",
122
123
  "config",
123
124
  "runner",
@@ -55,7 +55,10 @@ def run_dir(run):
55
55
  def load_state(run):
56
56
  p = run_dir(run) / "state.json"
57
57
  if not p.exists():
58
- raise SystemExit(f"No factory run '{run}'. Run `factory.py init` first.")
58
+ raise SystemExit(
59
+ f"No factory run '{run}'. Pass --run <name> (or run "
60
+ f"`factory.py init --run <name>` first)."
61
+ )
59
62
  return json.loads(p.read_text(encoding="utf-8"))
60
63
 
61
64
 
@@ -82,6 +85,19 @@ def cmd_phase_add(args):
82
85
  state = load_state(args.run)
83
86
  if args.phase in state["phases"]:
84
87
  raise SystemExit(f"Phase '{args.phase}' already exists.")
88
+ # Phase output goes to phase.json — a run-state helper must never clobber the
89
+ # author-written GOAL.md / dossier.json it is meant to track (#6 item 2.3).
90
+ # This check runs BEFORE any state mutation so a refusal leaves no trace.
91
+ phase_dir = ROADMAP_DIR / args.phase
92
+ authored = [
93
+ name for name in ("GOAL.md", "dossier.json") if (phase_dir / name).exists()
94
+ ]
95
+ if authored and not getattr(args, "force", False):
96
+ raise SystemExit(
97
+ f"Refusing to touch author-written file(s) in {phase_dir}: "
98
+ f"{', '.join(authored)}. phase-add now writes phase.json only; "
99
+ f"re-run with --force to overwrite."
100
+ )
85
101
  state["phases"][args.phase] = {
86
102
  "goal": args.goal,
87
103
  "acceptance": [a.strip() for a in args.accept.split(";") if a.strip()],
@@ -90,30 +106,52 @@ def cmd_phase_add(args):
90
106
  "qa_reports": [],
91
107
  }
92
108
  save_state(args.run, state)
93
- # Phase output: GOAL.md + dossier.json skeleton (evidence filled by manager).
94
- phase_dir = ROADMAP_DIR / args.phase
95
109
  phase_dir.mkdir(parents=True, exist_ok=True)
96
- (phase_dir / "GOAL.md").write_text(
97
- f"# {args.phase}: {args.goal}\n\n## Acceptance\n"
98
- + "".join(f"- [ ] {a}\n" for a in state["phases"][args.phase]["acceptance"]),
99
- encoding="utf-8",
100
- )
101
- (phase_dir / "dossier.json").write_text(
110
+ (phase_dir / "phase.json").write_text(
102
111
  json.dumps(
103
112
  {
104
113
  "phase": args.phase,
105
114
  "goal": args.goal,
106
- "evidence": [],
107
115
  "acceptance": state["phases"][args.phase]["acceptance"],
108
- "brief": "",
109
- "verdict": "briefed",
110
- "hashes": [],
116
+ "status": "briefed",
111
117
  },
112
118
  indent=2,
113
119
  ),
114
120
  encoding="utf-8",
115
121
  )
116
- print(f"✅ Phase '{args.phase}' briefed → {phase_dir}")
122
+ print(f"✅ Phase '{args.phase}' briefed → {phase_dir / 'phase.json'}")
123
+
124
+
125
+ def cmd_gate(args):
126
+ """First-class gate bookkeeping (open/settle/approve/waive/escalate/count)."""
127
+ state = load_state(args.run)
128
+ gates = state.setdefault("gates", {})
129
+ gate = gates.setdefault(
130
+ args.phase, {"status": "closed", "cycles": 0, "waivers": [], "history": []}
131
+ )
132
+ action = (args.action or "").lower()
133
+ if action == "open":
134
+ gate["status"] = "open"
135
+ gate["cycles"] = gate.get("cycles", 0) + 1
136
+ state["gate_cycles"] = state.get("gate_cycles", 0) + 1
137
+ elif action == "settle":
138
+ gate["status"] = "settled"
139
+ elif action == "approve":
140
+ gate["status"] = "approved"
141
+ elif action == "waive":
142
+ gate["status"] = "waived"
143
+ gate["waivers"].append(args.reason or "")
144
+ elif action == "escalate":
145
+ gate["status"] = "escalated"
146
+ gate["cycles"] = gate.get("cycles", 0) + 1
147
+ state["gate_cycles"] = state.get("gate_cycles", 0) + 1
148
+ elif action != "count":
149
+ raise SystemExit(
150
+ "gate --action must be one of: open, settle, approve, waive, escalate, count"
151
+ )
152
+ gate["history"].append({"action": action, "at": now(), "reason": args.reason or ""})
153
+ save_state(args.run, state)
154
+ print(json.dumps({"phase": args.phase, "gate": gate}, indent=2))
117
155
 
118
156
 
119
157
  def cmd_qa_record(args):
@@ -211,6 +249,11 @@ def main():
211
249
  p.add_argument("--phase", required=True)
212
250
  p.add_argument("--goal", required=True)
213
251
  p.add_argument("--accept", default="")
252
+ p.add_argument(
253
+ "--force",
254
+ action="store_true",
255
+ help="Overwrite author-written GOAL.md/dossier.json if present",
256
+ )
214
257
  _add_common(p)
215
258
  p = sub.add_parser("qa-record")
216
259
  p.add_argument("--run", required=True)
@@ -227,6 +270,16 @@ def main():
227
270
  p = sub.add_parser("stop")
228
271
  p.add_argument("--run", required=True)
229
272
  _add_common(p)
273
+ p = sub.add_parser("gate")
274
+ p.add_argument("--run", required=True)
275
+ p.add_argument("--phase", required=True)
276
+ p.add_argument(
277
+ "--action",
278
+ required=True,
279
+ choices=["open", "settle", "approve", "waive", "escalate", "count"],
280
+ )
281
+ p.add_argument("--reason", default="")
282
+ _add_common(p)
230
283
 
231
284
  args = ap.parse_args()
232
285
  root = resolve_project_root(args.project_dir)
@@ -238,6 +291,7 @@ def main():
238
291
  "qa-record": cmd_qa_record,
239
292
  "expansion": cmd_expansion,
240
293
  "stop": cmd_stop,
294
+ "gate": cmd_gate,
241
295
  }[args.command](args)
242
296
  sys.exit(code if isinstance(code, int) else 0)
243
297
 
@@ -111,6 +111,8 @@ export const IUMBTEMS_TOOL_NAMES = [
111
111
  'iumbtems_set_domain_pack',
112
112
  'iumbtems_export_brief',
113
113
  'iumbtems_verify_brief',
114
+ 'iumbtems_doctor',
115
+ 'iumbtems_test',
114
116
  ];
115
117
 
116
118
  // Deprecated misspelled alias (pre-A2b export). Remove in a semver-major.
@@ -444,6 +446,25 @@ const TOOL_CATALOG = [
444
446
  required: ['brief'],
445
447
  },
446
448
  },
449
+ {
450
+ name: 'iumbtems_doctor',
451
+ description:
452
+ 'Preflight health check for the resolved workspace: plugin version, backend binary, search-engine reachability, and workspace writability. Run this first when anything looks wrong.',
453
+ input: {
454
+ type: 'object',
455
+ properties: {
456
+ base_dir: { type: 'string', description: 'Project root (default: IUMBTEMS_PROJECT_DIR or cwd)' },
457
+ mode: { type: 'string', description: 'Operating mode to report for' },
458
+ probe: { type: 'boolean', description: 'Run a live 1-query search probe (default true)' },
459
+ },
460
+ },
461
+ },
462
+ {
463
+ name: 'iumbtems_test',
464
+ description:
465
+ 'Run the packaged IUMBTEMS test suite (subprocess, timeout-bounded) and report pass/fail with the tail of the output.',
466
+ input: { type: 'object', properties: {} },
467
+ },
447
468
  ];
448
469
 
449
470
  /** Legacy arg shims -> MCP handler contract. */
@@ -42,7 +42,20 @@ Always challenge default premises in Round 1:
42
42
 
43
43
  ## 5. FRONTIER RESOLUTION & FREEZING
44
44
 
45
+ The frontier is fully tool-drivable (no hand-written JSON):
46
+
47
+ ```bash
48
+ # author a node (prerequisites gate when it appears on the frontier)
49
+ python3 skills/grilling/socratic_tree.py --add-node --id N1 \
50
+ --question "L1 or L2 for state verification?" --depends-on ""
51
+ # settle a node
52
+ python3 skills/grilling/socratic_tree.py --settle N1 "L1 for finality"
53
+ # inspect / freeze
54
+ python3 skills/grilling/socratic_tree.py --show-frontier
55
+ python3 skills/grilling/socratic_tree.py --export
56
+ ```
57
+
45
58
  When every branch of the design tree has been visited and the frontier is empty:
46
59
  1. Summarize the settled constraints.
47
- 2. Save the settled state to `.research/frontier.json` using `python3 skills/grilling/socratic_tree.py --export`.
60
+ 2. Freeze the settled state to `.research/frontier.json` (`--export`, default path).
48
61
  3. Hand off the settled frontier to the **Swarm Orchestrator** to begin empirical dialectic execution.
@@ -11,10 +11,17 @@ import argparse
11
11
  from pathlib import Path
12
12
  from typing import Dict, List, Optional, Any
13
13
 
14
+
14
15
  class DecisionNode:
15
- def __init__(self, node_id: str, title: str, question: str,
16
- recommended: str, options: Optional[List[str]] = None,
17
- prerequisites: Optional[List[str]] = None):
16
+ def __init__(
17
+ self,
18
+ node_id: str,
19
+ title: str,
20
+ question: str,
21
+ recommended: str,
22
+ options: Optional[List[str]] = None,
23
+ prerequisites: Optional[List[str]] = None,
24
+ ):
18
25
  self.node_id = node_id
19
26
  self.title = title
20
27
  self.question = question
@@ -34,18 +41,18 @@ class DecisionNode:
34
41
  "recommended": self.recommended,
35
42
  "options": self.options,
36
43
  "prerequisites": self.prerequisites,
37
- "settled_answer": self.settled_answer
44
+ "settled_answer": self.settled_answer,
38
45
  }
39
46
 
40
47
  @classmethod
41
- def from_dict(cls, data: Dict[str, Any]) -> 'DecisionNode':
48
+ def from_dict(cls, data: Dict[str, Any]) -> "DecisionNode":
42
49
  node = cls(
43
50
  node_id=data["node_id"],
44
51
  title=data["title"],
45
52
  question=data["question"],
46
53
  recommended=data["recommended"],
47
54
  options=data.get("options", []),
48
- prerequisites=data.get("prerequisites", [])
55
+ prerequisites=data.get("prerequisites", []),
49
56
  )
50
57
  node.settled_answer = data.get("settled_answer")
51
58
  return node
@@ -82,7 +89,9 @@ class DesignTree:
82
89
  self.nodes[node_id].settled_answer = answer
83
90
 
84
91
  def is_complete(self) -> bool:
85
- return len(self.compute_frontier()) == 0 and all(n.is_settled() for n in self.nodes.values())
92
+ return len(self.compute_frontier()) == 0 and all(
93
+ n.is_settled() for n in self.nodes.values()
94
+ )
86
95
 
87
96
  def export_frontier_json(self, output_path: Path):
88
97
  safe_path = Path(os.path.realpath(str(output_path)))
@@ -93,13 +102,13 @@ class DesignTree:
93
102
  "nodes": {k: v.to_dict() for k, v in self.nodes.items()},
94
103
  "settled_constraints": {
95
104
  k: v.settled_answer for k, v in self.nodes.items() if v.is_settled()
96
- }
105
+ },
97
106
  }
98
107
  with open(safe_path, "w", encoding="utf-8") as f:
99
108
  json.dump(data, f, indent=2)
100
109
 
101
110
  @classmethod
102
- def load_from_json(cls, file_path: Path) -> 'DesignTree':
111
+ def load_from_json(cls, file_path: Path) -> "DesignTree":
103
112
  safe_path = Path(os.path.realpath(str(file_path)))
104
113
  with open(safe_path, "r", encoding="utf-8") as f:
105
114
  data = json.load(f)
@@ -111,12 +120,16 @@ class DesignTree:
111
120
 
112
121
  def _display_frontier(tree: DesignTree, frontier: list) -> None:
113
122
  print(f"\n🎯 Objective: {tree.objective}")
114
- print(f"📊 Total Nodes: {len(tree.nodes)} | Settled: {sum(1 for n in tree.nodes.values() if n.is_settled())}")
123
+ print(
124
+ f"📊 Total Nodes: {len(tree.nodes)} | Settled: {sum(1 for n in tree.nodes.values() if n.is_settled())}"
125
+ )
115
126
  if not frontier:
116
127
  if tree.nodes and tree.is_complete():
117
128
  print("✅ Frontier is EMPTY. All prerequisite branches are fully settled!")
118
129
  else:
119
- print("ℹ️ No active frontier nodes. Define new decision nodes to begin grilling.")
130
+ print(
131
+ "ℹ️ No active frontier nodes. Define new decision nodes to begin grilling."
132
+ )
120
133
  else:
121
134
  print(f"\n⚡ Current Active Frontier ({len(frontier)} questions ready):")
122
135
  for idx, node in enumerate(frontier, 1):
@@ -126,11 +139,49 @@ def _display_frontier(tree: DesignTree, frontier: list) -> None:
126
139
 
127
140
 
128
141
  def main():
129
- parser = argparse.ArgumentParser(description="Epistemic Swarm Socratic Decision Tree")
142
+ parser = argparse.ArgumentParser(
143
+ description="Epistemic Swarm Socratic Decision Tree"
144
+ )
130
145
  parser.add_argument("--objective", type=str, help="Research objective")
131
- parser.add_argument("--file", type=str, default=".research/frontier.json", help="Path to frontier.json")
132
- parser.add_argument("--show-frontier", action="store_true", help="Print the current decision frontier")
133
- parser.add_argument("--settle", nargs=2, metavar=("NODE_ID", "ANSWER"), help="Settle a decision node")
146
+ parser.add_argument(
147
+ "--file",
148
+ type=str,
149
+ default=".research/frontier.json",
150
+ help="Path to frontier.json",
151
+ )
152
+ parser.add_argument(
153
+ "--show-frontier",
154
+ action="store_true",
155
+ help="Print the current decision frontier",
156
+ )
157
+ parser.add_argument(
158
+ "--settle",
159
+ nargs=2,
160
+ metavar=("NODE_ID", "ANSWER"),
161
+ help="Settle a decision node",
162
+ )
163
+ parser.add_argument(
164
+ "--add-node",
165
+ action="store_true",
166
+ help="Add a decision node (with --id/--question)",
167
+ )
168
+ parser.add_argument("--id", type=str, help="Node id (with --add-node)")
169
+ parser.add_argument("--question", type=str, help="Node question (with --add-node)")
170
+ parser.add_argument(
171
+ "--title", type=str, default="", help="Node title (defaults to --id)"
172
+ )
173
+ parser.add_argument(
174
+ "--recommended", type=str, default="", help="Recommended option"
175
+ )
176
+ parser.add_argument(
177
+ "--depends-on",
178
+ type=str,
179
+ default="",
180
+ help="Comma-separated prerequisite node ids",
181
+ )
182
+ parser.add_argument(
183
+ "--export", action="store_true", help="Write the frontier JSON and exit"
184
+ )
134
185
 
135
186
  args = parser.parse_args()
136
187
  frontier_path = Path(os.path.realpath(str(args.file)))
@@ -141,14 +192,35 @@ def main():
141
192
  objective = args.objective or "Epistemic Swarm Research Objective"
142
193
  tree = DesignTree(objective=objective)
143
194
 
195
+ if args.add_node:
196
+ if not args.id or not args.question:
197
+ parser.error("--add-node requires --id and --question")
198
+ prereqs = [p.strip() for p in (args.depends_on or "").split(",") if p.strip()]
199
+ tree.add_node(
200
+ DecisionNode(
201
+ node_id=args.id,
202
+ title=args.title or args.id,
203
+ question=args.question,
204
+ recommended=args.recommended or "",
205
+ options=[],
206
+ prerequisites=prereqs,
207
+ )
208
+ )
209
+ tree.export_frontier_json(frontier_path)
210
+ print(f"Added node {args.id} -> {frontier_path}")
211
+
144
212
  if args.settle:
145
213
  node_id, answer = args.settle
146
214
  tree.settle_node(node_id, answer)
147
215
  tree.export_frontier_json(frontier_path)
148
216
  print(f"Settled {node_id} -> {answer}")
149
217
 
218
+ if args.export:
219
+ tree.export_frontier_json(frontier_path)
220
+ print(f"Exported frontier -> {frontier_path}")
221
+
150
222
  frontier = tree.compute_frontier()
151
- if args.show_frontier or not args.settle:
223
+ if args.show_frontier or not (args.settle or args.add_node or args.export):
152
224
  _display_frontier(tree, frontier)
153
225
 
154
226
 
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Structured, typed errors (Heretek-AI/IUMBTEMS#6 item 3.10).
3
+
4
+ The 0.7.10 diagnostic was the most valuable output in the reporter's session, so
5
+ this generalises it: every actionable failure carries a code, the path that was
6
+ searched, where output actually went, the resolved workspace, the spawn cwd, and
7
+ a suggested fix. An agent can act on that; it cannot act on `FileNotFoundError`.
8
+ """
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Any, Dict, Optional
12
+
13
+
14
+ @dataclass
15
+ class SwarmError(Exception):
16
+ """Base typed error with the diagnostic fields an agent needs."""
17
+
18
+ code: str
19
+ message: str
20
+ path_searched: Optional[str] = None
21
+ path_written: Optional[str] = None
22
+ workspace: Optional[str] = None
23
+ spawn_cwd: Optional[str] = None
24
+ suggested_fix: Optional[str] = None
25
+ details: Dict[str, Any] = field(default_factory=dict)
26
+
27
+ def __post_init__(self) -> None:
28
+ super().__init__(self.message)
29
+
30
+ def to_dict(self) -> Dict[str, Any]:
31
+ return {k: v for k, v in asdict(self).items() if v not in (None, {}, "")}
32
+
33
+ def __str__(self) -> str:
34
+ lines = [f"[{self.code}] {self.message}"]
35
+ for label, value in (
36
+ ("path_searched", self.path_searched),
37
+ ("path_written", self.path_written),
38
+ ("workspace", self.workspace),
39
+ ("spawn_cwd", self.spawn_cwd),
40
+ ("suggested_fix", self.suggested_fix),
41
+ ):
42
+ if value:
43
+ lines.append(f" {label}: {value}")
44
+ return "\n".join(lines)
45
+
46
+
47
+ class DossierNotFound(SwarmError, FileNotFoundError):
48
+ """A required role dossier is absent.
49
+
50
+ Subclasses FileNotFoundError so existing callers/tests keep working.
51
+ """