@heretek-ai/epistemic-swarm 0.7.19 → 0.7.21

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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +23 -2
  3. package/package.json +1 -1
  4. package/plugins/antigravity/plugin.json +1 -1
  5. package/plugins/antigravity/rules/AGENTS.md +5 -3
  6. package/plugins/opencode/config-io.js +55 -12
  7. package/plugins/opencode/index.js +611 -0
  8. package/plugins/opencode/tui.js +278 -6
  9. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  29. package/runner/mcp_server.py +81 -10
  30. package/runner/research_swarm.py +12 -2
  31. package/runner/schemas.py +18 -0
  32. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  63. package/runner/tests/test_config_schema.py +106 -0
  64. package/runner/tests/test_opencode_ux.py +462 -7
  65. package/runner/tests/test_opencode_v2_registrars.py +1112 -3
  66. package/runner/tests/test_swarm_config.py +21 -0
  67. package/schemas/config.schema.json +11 -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/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  74. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  75. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  76. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  77. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  78. package/skills/swarm_config/SKILL.md +26 -0
  79. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  80. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  81. package/skills/swarm_config/configure.py +40 -6
@@ -86,6 +86,7 @@ VALID_UPDATES = {
86
86
  },
87
87
  "opencode_auto": False,
88
88
  "opencode_agent": "darkharvester",
89
+ "mcp_servers": {"searxng": False, "firecrawl": True},
89
90
  }
90
91
 
91
92
  # One clearly invalid value per canonical key.
@@ -107,6 +108,7 @@ INVALID_UPDATES = {
107
108
  "agents": "claude -p",
108
109
  "opencode_auto": "yes",
109
110
  "opencode_agent": 5,
111
+ "mcp_servers": "enabled",
110
112
  }
111
113
 
112
114
 
@@ -319,6 +321,71 @@ class TestMcpConfigSurface(unittest.TestCase):
319
321
  self.assertFalse((Path(tmp) / "config.json").exists())
320
322
  shown = self._call({"base_dir": tmp, "show": True})
321
323
  self.assertEqual(shown["status"], "current")
324
+ # R11: the legacy-pin case must NOT write (the old test was vacuous
325
+ # here). Seed a pin, snapshot the bytes, and prove the show leg
326
+ # leaves them byte-identical while still reporting the migration.
327
+ cfg_file = _write_config(
328
+ Path(tmp), {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
329
+ )
330
+ before = cfg_file.read_bytes()
331
+ pinned = self._call({"base_dir": tmp})
332
+ self.assertEqual(pinned["status"], "current")
333
+ self.assertTrue(pinned["migrated"])
334
+ self.assertEqual(cfg_file.read_bytes(), before)
335
+
336
+ def test_write_leg_heals_legacy_pin_and_persists(self):
337
+ # R11 preserves phase-01 A6: a write still migrates the legacy pin on disk.
338
+ with tempfile.TemporaryDirectory() as tmp:
339
+ base = Path(tmp)
340
+ cfg_file = _write_config(
341
+ base, {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
342
+ )
343
+ payload = self._call({"max_iterations": 2, "base_dir": tmp})
344
+ self.assertEqual(payload["status"], "updated", payload)
345
+ healed = json.loads(cfg_file.read_text(encoding="utf-8"))
346
+ self.assertIsNone(healed["agents"]["alpha"]["backend"])
347
+ self.assertEqual(healed["max_iterations"], 2)
348
+
349
+ def test_guarded_write_on_legacy_pin_succeeds_and_heals(self):
350
+ # F1/R12: the wizard always sends `expected_hash`. With no heal-before-
351
+ # save, the guard still matches so the write is `updated` (never a false
352
+ # `stale`) and the pin is migrated on disk.
353
+ with tempfile.TemporaryDirectory() as tmp:
354
+ base = Path(tmp)
355
+ cfg_file = _write_config(
356
+ base, {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
357
+ )
358
+ start_hash = config_hash(tmp)
359
+ payload = self._call(
360
+ {"max_iterations": 3, "expected_hash": start_hash, "base_dir": tmp}
361
+ )
362
+ self.assertEqual(payload["status"], "updated", payload)
363
+ healed = json.loads(cfg_file.read_text(encoding="utf-8"))
364
+ self.assertIsNone(healed["agents"]["alpha"]["backend"])
365
+ self.assertEqual(healed["max_iterations"], 3)
366
+
367
+ def test_invalid_pin_file_show_reports_no_migration_and_write_blocked(self):
368
+ # R12/R11: an invalid file is never healed, and a write that would leave
369
+ # it invalid is rejected without touching the bytes.
370
+ with tempfile.TemporaryDirectory() as tmp:
371
+ base = Path(tmp)
372
+ cfg_file = _write_config(
373
+ base,
374
+ {
375
+ "max_iterations": 99,
376
+ "agents": {"alpha": {"backend": ["claude", "-p"]}},
377
+ },
378
+ )
379
+ before = cfg_file.read_bytes()
380
+ shown = self._call({"base_dir": tmp})
381
+ self.assertEqual(shown["status"], "current")
382
+ self.assertFalse(shown["migrated"])
383
+ self.assertEqual(cfg_file.read_bytes(), before)
384
+ blocked = self._call({"mode": "audit", "base_dir": tmp})
385
+ self.assertEqual(blocked["status"], "error")
386
+ self.assertEqual(blocked["code"], "CONFIG_VALIDATION_FAILED")
387
+ self.assertFalse(blocked["written"])
388
+ self.assertEqual(cfg_file.read_bytes(), before)
322
389
 
323
390
  def test_null_clears_a_nullable_key(self):
324
391
  with tempfile.TemporaryDirectory() as tmp:
@@ -439,6 +506,45 @@ class TestMcpConfigSurface(unittest.TestCase):
439
506
  self.assertEqual(healed["max_iterations"], 2)
440
507
  self.assertIsNone(healed["agents"]["alpha"]["backend"])
441
508
 
509
+ def test_updated_payload_is_single_read_coherent(self):
510
+ """R10: `updated` config + hash come from ONE read of the written bytes.
511
+
512
+ Regression against the pre-R10 shape (`load_config()` then
513
+ `config_hash()` as two independent reads): a write landing between the
514
+ two would pair an era-A config with an era-B hash. We inject exactly
515
+ that write into `config_hash`; a single-read implementation never calls
516
+ it, so the returned config/hash stay coherent with the bytes on disk.
517
+ """
518
+ import hashlib
519
+
520
+ from skills.swarm_config import configure
521
+
522
+ with tempfile.TemporaryDirectory() as tmp:
523
+ original = configure.config_hash
524
+
525
+ def torn(base):
526
+ # Simulate a concurrent writer landing era-B between the old
527
+ # shape's two reads, immediately before the hash read.
528
+ cfg = Path(base) / "config.json"
529
+ data = json.loads(cfg.read_text(encoding="utf-8"))
530
+ data["mode"] = "audit"
531
+ cfg.write_text(json.dumps(data), encoding="utf-8")
532
+ return original(base)
533
+
534
+ configure.config_hash = torn
535
+ try:
536
+ payload = self._call({"mode": "scout", "base_dir": tmp})
537
+ finally:
538
+ configure.config_hash = original
539
+
540
+ self.assertEqual(payload["status"], "updated")
541
+ written = (Path(tmp) / "config.json").read_bytes()
542
+ # The hash covers the bytes the config was derived from ...
543
+ self.assertEqual(payload["hash"], hashlib.sha256(written).hexdigest())
544
+ # ... and the config is that same era (never scout + audit-hash).
545
+ self.assertEqual(payload["config"]["mode"], json.loads(written)["mode"])
546
+ self.assertEqual(payload["config"], load_config(tmp))
547
+
442
548
  def test_output_dir_deprecation_is_inert(self):
443
549
  with tempfile.TemporaryDirectory() as tmp:
444
550
  base = Path(tmp)
@@ -911,8 +911,8 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
911
911
  res = run_node(
912
912
  f"""
913
913
  import plugin from "./plugins/opencode/index.js";
914
- let hookName = null;
915
- let handler = null;
914
+ const hookNames = [];
915
+ let compactionHandler = null;
916
916
  const host = {{
917
917
  options: {{}},
918
918
  location: {{directory: {str(root)!r}}},
@@ -920,16 +920,16 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
920
920
  tool: {{transform: async () => ({{dispose: async () => {{}}}}), reload: async () => {{}}}},
921
921
  session: {{
922
922
  prompt: async () => ({{}}),
923
- hook: async (name, fn) => {{ hookName = name; handler = fn; return {{dispose: async () => {{}}}}; }}
923
+ hook: async (name, fn) => {{ hookNames.push(name); if (name === "compaction") compactionHandler = fn; return {{dispose: async () => {{}}}}; }}
924
924
  }}
925
925
  }};
926
926
  await plugin.setup(host);
927
927
  const event = {{system: [], directory: {str(root)!r}}};
928
- if (handler) await handler(event);
928
+ if (compactionHandler) await compactionHandler(event);
929
929
  const emptyEvent = {{system: [], directory: "/tmp/oc-v2-compaction-empty-nonexistent"}};
930
- if (handler) await handler(emptyEvent);
930
+ if (compactionHandler) await compactionHandler(emptyEvent);
931
931
  console.log(JSON.stringify({{
932
- hookName,
932
+ hookNames,
933
933
  parts: event.system.map((p) => ({{type: p.type, text: p.text || ""}})),
934
934
  emptySkipped: emptyEvent.system.length
935
935
  }}));
@@ -939,7 +939,7 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
939
939
  res.returncode, 0, f"compaction hook test failed: {res.stderr}"
940
940
  )
941
941
  data = last_json_object(res.stdout)
942
- self.assertEqual(data["hookName"], "compaction")
942
+ self.assertIn("compaction", data["hookNames"])
943
943
  self.assertEqual(
944
944
  data["emptySkipped"], 0, "empty workspace must not push state"
945
945
  )
@@ -1747,5 +1747,460 @@ REPLAY_SINGLE = """
1747
1747
  """
1748
1748
 
1749
1749
 
1750
+ # ---------------------------------------------------------------------------
1751
+ # Phase 03 (opencode-settings-menu): TUI prefers the `iumbtems.settings` RPC
1752
+ # (degraded direct-fs fallback with toast), `changed` subscription with the
1753
+ # 5s poll as floor, and UI-only prefs that never affect run-affecting values.
1754
+ # ---------------------------------------------------------------------------
1755
+
1756
+ RPC_TUI_HARNESS = """
1757
+ import {
1758
+ openSettingsWizard, openSettingsPanel,
1759
+ subscribeSettingsChanged, getSettingsRpcClient,
1760
+ readUiPrefs, writeUiPrefs,
1761
+ SETTINGS_POLL_MS, UI_PREFS_KEY,
1762
+ } from "./plugins/opencode/tui.js";
1763
+ import { SettingsRpc } from "./plugins/opencode/index.js";
1764
+ import { loadConfigFromRaw } from "./plugins/opencode/config-io.js";
1765
+ import fs from "node:fs";
1766
+ import path from "node:path";
1767
+
1768
+ // Realistic RPC snapshot (phase-04 R1): `raw` is the parsed ON-DISK file config;
1769
+ // `config` is the defaults-merged EFFECTIVE config (the production `get` builds
1770
+ // it with `loadConfigFromRaw`). A partial-config mock here is exactly what let
1771
+ // the RPC-path provenance defect ship.
1772
+ function rpcState(raw, hash) {
1773
+ const onDisk = raw && typeof raw === "object" ? raw : {};
1774
+ return {
1775
+ raw: { ...onDisk },
1776
+ config: loadConfigFromRaw(onDisk),
1777
+ hash, setCalls: [], subs: [], unsubbed: false, staleAlways: false,
1778
+ };
1779
+ }
1780
+ function rpcClient(state) {
1781
+ const snapshot = () => ({ config: { ...state.config }, raw: { ...state.raw }, hash: state.hash });
1782
+ return {
1783
+ get: async () => snapshot(),
1784
+ set: async (input) => {
1785
+ state.setCalls.push(input);
1786
+ const stale = () => {
1787
+ const e = new Error("stale");
1788
+ e.type = "stale";
1789
+ e.data = { current_hash: state.hash, config: { ...state.config } };
1790
+ throw e;
1791
+ };
1792
+ if (state.staleAlways) stale();
1793
+ const exp = input?.expected_hash !== undefined ? input.expected_hash : input?.expectedHash;
1794
+ if (exp !== undefined && exp !== null && exp !== state.hash) stale();
1795
+ state.raw = { ...state.raw, ...(input?.updates || {}) };
1796
+ state.config = loadConfigFromRaw(state.raw);
1797
+ state.hash = `hash${state.setCalls.length}`;
1798
+ return snapshot();
1799
+ },
1800
+ events: {
1801
+ on: (name, fn) => { state.subs.push({ name, fn }); return () => { state.unsubbed = true; }; },
1802
+ },
1803
+ };
1804
+ }
1805
+ function tuiStore() {
1806
+ const map = new Map();
1807
+ return {
1808
+ map,
1809
+ storage: {
1810
+ store: (key, { initial }) => {
1811
+ const cur = map.has(key) ? map.get(key) : initial;
1812
+ return [
1813
+ { ...((cur || {})) },
1814
+ async (mut) => {
1815
+ const d = { ...(((map.has(key) ? map.get(key) : initial)) || {}) };
1816
+ mut(d);
1817
+ map.set(key, d);
1818
+ },
1819
+ ];
1820
+ },
1821
+ },
1822
+ };
1823
+ }
1824
+ function dialogHost(queues) {
1825
+ const toasts = [];
1826
+ const pop = (arr) => ((!arr || arr.length === 0) ? null : arr.shift());
1827
+ return {
1828
+ toasts,
1829
+ ui: {
1830
+ toast: { show: (t) => toasts.push(t) },
1831
+ dialog: {
1832
+ select: async (a) => pop(queues.select),
1833
+ prompt: async (a) => pop(queues.prompt),
1834
+ confirm: async (a) => pop(queues.confirm),
1835
+ },
1836
+ panel: async (p) => { dialogHost._panel = p; return {}; },
1837
+ },
1838
+ };
1839
+ }
1840
+ """
1841
+
1842
+
1843
+ class TestRpcPreferredPath(unittest.TestCase):
1844
+ def test_wizard_prefers_rpc_and_writes_nothing_to_disk(self):
1845
+ # C2 (preferred leg): with `client.rpc` the wizard round-trips through
1846
+ # the RPC subclient; the workspace is never touched on disk.
1847
+ with tempfile.TemporaryDirectory() as tmp:
1848
+ res = run_node(
1849
+ RPC_TUI_HARNESS
1850
+ + f"""
1851
+ const root = {tmp!r};
1852
+ const state = rpcState({{ search_engine: "duckduckgo" }}, "h0");
1853
+ const store = tuiStore();
1854
+ const h = dialogHost({{ select: ["search_engine", "brave", "__iumbtems_save__"], confirm: [true] }});
1855
+ const host = {{
1856
+ directory: root, client: {{ rpc: (def) => rpcClient(state) }},
1857
+ storage: store.storage, ui: h.ui,
1858
+ }};
1859
+ const r = await openSettingsWizard(host, root, {{ env: {{}} }});
1860
+ const toasts = h.toasts.map((t) => JSON.stringify(t)).join("\\n");
1861
+ console.log(JSON.stringify({{
1862
+ ok: r.ok, written: r.written, hash: r.hash,
1863
+ setCalls: state.setCalls,
1864
+ files: (function walk(d) {{
1865
+ try {{ return fs.readdirSync(d); }} catch {{ return []; }}
1866
+ }})(root),
1867
+ degraded: toasts.includes("degraded"),
1868
+ viaRpc: toasts.includes("server RPC"),
1869
+ prefs: store.map.get(UI_PREFS_KEY) || null,
1870
+ }}));
1871
+ """
1872
+ )
1873
+ self.assertEqual(res.returncode, 0, f"rpc wizard failed: {res.stderr}")
1874
+ data = last_json_object(res.stdout)
1875
+ self.assertTrue(data["ok"])
1876
+ self.assertTrue(data["written"])
1877
+ self.assertEqual(len(data["setCalls"]), 1)
1878
+ self.assertEqual(data["setCalls"][0]["updates"]["search_engine"], "brave")
1879
+ self.assertEqual(data["setCalls"][0]["expectedHash"], "h0")
1880
+ self.assertEqual(data["hash"], "hash1")
1881
+ # Nothing written to disk: the RPC is the write path.
1882
+ self.assertEqual(data["files"], [])
1883
+ self.assertFalse(data["degraded"])
1884
+ self.assertTrue(data["viaRpc"])
1885
+ # UI prefs recorded the save; nothing else.
1886
+ self.assertIn("lastSavedAt", data["prefs"])
1887
+
1888
+ def test_wizard_falls_back_without_client_with_toast(self):
1889
+ # C2 (fallback leg): no `client.rpc` -> direct-fs write + toast, same
1890
+ # validation and hash guard as before.
1891
+ with tempfile.TemporaryDirectory() as tmp:
1892
+ res = run_node(
1893
+ RPC_TUI_HARNESS
1894
+ + f"""
1895
+ const root = {tmp!r};
1896
+ const h = dialogHost({{ select: ["search_engine", "brave", "__iumbtems_save__"], confirm: [true] }});
1897
+ const host = {{ directory: root, ui: h.ui }};
1898
+ const r = await openSettingsWizard(host, root, {{ env: {{}} }});
1899
+ const saved = JSON.parse(fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8"));
1900
+ const toasts = h.toasts.map((t) => JSON.stringify(t)).join("\\n");
1901
+ console.log(JSON.stringify({{
1902
+ ok: r.ok, written: r.written,
1903
+ engine: saved.search_engine,
1904
+ degraded: toasts.includes("degraded path"),
1905
+ }}));
1906
+ """
1907
+ )
1908
+ self.assertEqual(res.returncode, 0, f"fallback failed: {res.stderr}")
1909
+ data = last_json_object(res.stdout)
1910
+ self.assertTrue(data["ok"])
1911
+ self.assertTrue(data["written"])
1912
+ self.assertEqual(data["engine"], "brave")
1913
+ self.assertTrue(data["degraded"])
1914
+
1915
+ def test_wizard_rpc_stale_maps_to_stale_result(self):
1916
+ # C3 at the TUI: a stale RPC write surfaces the stale outcome and
1917
+ # writes nothing anywhere.
1918
+ with tempfile.TemporaryDirectory() as tmp:
1919
+ res = run_node(
1920
+ RPC_TUI_HARNESS
1921
+ + f"""
1922
+ const root = {tmp!r};
1923
+ const state = rpcState({{ search_engine: "duckduckgo" }}, "h0");
1924
+ state.staleAlways = true;
1925
+ const h = dialogHost({{ select: ["search_engine", "brave", "__iumbtems_save__"], confirm: [true] }});
1926
+ const host = {{
1927
+ directory: root, client: {{ rpc: (def) => rpcClient(state) }}, ui: h.ui,
1928
+ }};
1929
+ const r = await openSettingsWizard(host, root, {{ env: {{}} }});
1930
+ const toasts = h.toasts.map((t) => JSON.stringify(t)).join("\\n");
1931
+ let files = [];
1932
+ try {{ files = fs.readdirSync(root); }} catch {{ files = []; }}
1933
+ console.log(JSON.stringify({{
1934
+ ok: r.ok, reason: r.reason, written: r.written,
1935
+ staleToast: toasts.includes("refusing the stale write"),
1936
+ files,
1937
+ }}));
1938
+ """
1939
+ )
1940
+ self.assertEqual(res.returncode, 0, f"stale wizard failed: {res.stderr}")
1941
+ data = last_json_object(res.stdout)
1942
+ self.assertFalse(data["ok"])
1943
+ self.assertEqual(data["reason"], "stale")
1944
+ self.assertFalse(data["written"])
1945
+ self.assertTrue(data["staleToast"])
1946
+ self.assertEqual(data["files"], [])
1947
+
1948
+ def test_panel_prefers_rpc_snapshot_without_writing(self):
1949
+ # The panel never writes: seed a real file (the single-read snapshot
1950
+ # serves it) and prove the bytes are untouched.
1951
+ with tempfile.TemporaryDirectory() as tmp:
1952
+ research = Path(tmp) / ".research"
1953
+ research.mkdir()
1954
+ seed = json.dumps({"mode": "audit", "search_engine": "brave"})
1955
+ research.joinpath("config.json").write_text(seed, encoding="utf-8")
1956
+ res = run_node(
1957
+ RPC_TUI_HARNESS
1958
+ + f"""
1959
+ const root = {tmp!r};
1960
+ const state = rpcState({{ mode: "audit", search_engine: "brave" }}, "h0");
1961
+ const shown = [];
1962
+ const host = {{
1963
+ directory: root, client: {{ rpc: (def) => rpcClient(state) }},
1964
+ ui: {{ panel: async (p) => shown.push(p) }},
1965
+ }};
1966
+ const r = await openSettingsPanel(host, root, {{ env: {{}} }});
1967
+ const after = fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8");
1968
+ console.log(JSON.stringify({{
1969
+ ok: r.ok, degraded: r.degraded, body: shown[0].body,
1970
+ intact: after === {seed!r},
1971
+ }}));
1972
+ """
1973
+ )
1974
+ self.assertEqual(res.returncode, 0, f"rpc panel failed: {res.stderr}")
1975
+ data = last_json_object(res.stdout)
1976
+ self.assertTrue(data["ok"])
1977
+ self.assertFalse(data["degraded"])
1978
+ self.assertIn("audit", data["body"])
1979
+ self.assertIn("Operating Mode", data["body"])
1980
+ self.assertIn("applies to next swarm run", data["body"])
1981
+ self.assertTrue(data["intact"])
1982
+
1983
+ def test_panel_rpc_path_provenance_distinguishes_sources(self):
1984
+ # R1 (the shipped defect): on the PREFERRED RPC path, provenance must be
1985
+ # default | file | env-override, derived from the RPC `raw` (on-disk)
1986
+ # rather than the merged effective `config`. Pre-fix, every row was
1987
+ # `[file]` and `[default]` was unreachable ({file: 18}).
1988
+ res = run_node(
1989
+ RPC_TUI_HARNESS
1990
+ + """
1991
+ const state = rpcState({ search_engine: "brave" }, "h0");
1992
+ const shown = [];
1993
+ const host = {
1994
+ directory: "/tmp/oc-rpc-prov",
1995
+ client: { rpc: (def) => rpcClient(state) },
1996
+ ui: { panel: async (p) => shown.push(p) },
1997
+ };
1998
+ const r = await openSettingsPanel(host, "/tmp/oc-rpc-prov", {
1999
+ env: { IUMBTEMS_SEARCH_TIMEOUT_S: "9" },
2000
+ });
2001
+ const body = shown[0].body;
2002
+ const rowOf = (key) => body.split("\\n").find((l) => l.includes("(" + key + ")"));
2003
+ console.log(JSON.stringify({
2004
+ ok: r.ok,
2005
+ searchEngine: rowOf("search_engine"),
2006
+ mode: rowOf("mode"),
2007
+ timeout: rowOf("search_timeout_s"),
2008
+ fileCount: (body.match(/\\[file\\]/g) || []).length,
2009
+ defaultCount: (body.match(/\\[default\\]/g) || []).length,
2010
+ envCount: (body.match(/\\[env-override\\]/g) || []).length,
2011
+ }));
2012
+ """
2013
+ )
2014
+ self.assertEqual(res.returncode, 0, f"rpc provenance failed: {res.stderr}")
2015
+ d = last_json_object(res.stdout)
2016
+ self.assertTrue(d["ok"])
2017
+ # The on-disk file sets only `search_engine` -> `file`, and exactly one row.
2018
+ self.assertIn("[file]", d["searchEngine"])
2019
+ self.assertEqual(d["fileCount"], 1)
2020
+ # `default` is REACHABLE for keys the on-disk file does not set.
2021
+ self.assertIn("[default]", d["mode"])
2022
+ self.assertGreater(d["defaultCount"], 0)
2023
+ # env-override wins for the mapped key.
2024
+ self.assertIn("[env-override]", d["timeout"])
2025
+ self.assertEqual(d["envCount"], 1)
2026
+
2027
+ def test_panel_rpc_path_warns_on_malformed_config(self):
2028
+ # R10: the read-only panel must warn on a malformed config (the wizard
2029
+ # already does), never render silent defaults, never throw, never write.
2030
+ with tempfile.TemporaryDirectory() as tmp:
2031
+ research = Path(tmp) / ".research"
2032
+ research.mkdir()
2033
+ garbage = "{oops not json"
2034
+ research.joinpath("config.json").write_text(garbage, encoding="utf-8")
2035
+ res = run_node(
2036
+ RPC_TUI_HARNESS
2037
+ + f"""
2038
+ const root = {tmp!r};
2039
+ const state = rpcState({{}}, null);
2040
+ const shown = [];
2041
+ const host = {{
2042
+ directory: root, client: {{ rpc: (def) => rpcClient(state) }},
2043
+ ui: {{ panel: async (p) => shown.push(p) }},
2044
+ }};
2045
+ const r = await openSettingsPanel(host, root, {{ env: {{}} }});
2046
+ const body = (shown[0] && shown[0].body) || "";
2047
+ const after = fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8");
2048
+ console.log(JSON.stringify({{
2049
+ ok: r.ok,
2050
+ warns: body.includes("Could not parse"),
2051
+ hasRows: body.includes("effective values"),
2052
+ intact: after === {garbage!r},
2053
+ }}));
2054
+ """
2055
+ )
2056
+ self.assertEqual(res.returncode, 0, f"panel malformed failed: {res.stderr}")
2057
+ data = last_json_object(res.stdout)
2058
+ self.assertTrue(data["ok"])
2059
+ self.assertTrue(data["warns"])
2060
+ self.assertTrue(data["hasRows"])
2061
+ self.assertTrue(data["intact"])
2062
+
2063
+ def test_panel_rpc_path_no_notice_for_valid_config(self):
2064
+ # R10 companion: a valid config renders NO malformed notice.
2065
+ with tempfile.TemporaryDirectory() as tmp:
2066
+ research = Path(tmp) / ".research"
2067
+ research.mkdir()
2068
+ seed = json.dumps({"mode": "audit"})
2069
+ research.joinpath("config.json").write_text(seed, encoding="utf-8")
2070
+ res = run_node(
2071
+ RPC_TUI_HARNESS
2072
+ + f"""
2073
+ const root = {tmp!r};
2074
+ const state = rpcState({{ mode: "audit" }}, "h0");
2075
+ const shown = [];
2076
+ const host = {{
2077
+ directory: root, client: {{ rpc: (def) => rpcClient(state) }},
2078
+ ui: {{ panel: async (p) => shown.push(p) }},
2079
+ }};
2080
+ const r = await openSettingsPanel(host, root, {{ env: {{}} }});
2081
+ const body = (shown[0] && shown[0].body) || "";
2082
+ console.log(JSON.stringify({{
2083
+ ok: r.ok,
2084
+ warns: body.includes("Could not parse"),
2085
+ hasMode: body.includes("audit"),
2086
+ }}));
2087
+ """
2088
+ )
2089
+ self.assertEqual(res.returncode, 0, f"panel valid failed: {res.stderr}")
2090
+ data = last_json_object(res.stdout)
2091
+ self.assertTrue(data["ok"])
2092
+ self.assertFalse(data["warns"])
2093
+ self.assertTrue(data["hasMode"])
2094
+
2095
+ def test_wizard_rpc_path_warns_on_malformed_config(self):
2096
+ # Phase-03 R6 (phase-02 B2 on the primary path): corrupt config with
2097
+ # an RPC-preferred host warns instead of rendering silent defaults.
2098
+ with tempfile.TemporaryDirectory() as tmp:
2099
+ research = Path(tmp) / ".research"
2100
+ research.mkdir()
2101
+ garbage = "{oops not json"
2102
+ research.joinpath("config.json").write_text(garbage, encoding="utf-8")
2103
+ res = run_node(
2104
+ RPC_TUI_HARNESS
2105
+ + f"""
2106
+ const root = {tmp!r};
2107
+ const state = rpcState({{}}, null);
2108
+ const h = dialogHost({{ select: ["__iumbtems_cancel__"] }});
2109
+ const host = {{
2110
+ directory: root, client: {{ rpc: (def) => rpcClient(state) }}, ui: h.ui,
2111
+ }};
2112
+ const r = await openSettingsWizard(host, root, {{ env: {{}} }});
2113
+ const toasts = h.toasts.map((t) => JSON.stringify(t)).join("\\n");
2114
+ console.log(JSON.stringify({{
2115
+ cancelled: r.cancelled, written: r.written,
2116
+ warns: toasts.includes("Could not parse"),
2117
+ intact: fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8") === {garbage!r},
2118
+ }}));
2119
+ """
2120
+ )
2121
+ self.assertEqual(res.returncode, 0, f"malformed rpc failed: {res.stderr}")
2122
+ data = last_json_object(res.stdout)
2123
+ self.assertTrue(data["cancelled"])
2124
+ self.assertFalse(data["written"])
2125
+ self.assertTrue(data["warns"])
2126
+ self.assertTrue(data["intact"])
2127
+
2128
+
2129
+ class TestChangedSubscription(unittest.TestCase):
2130
+ def test_changed_event_reaches_subscriber_and_poll_floor_held(self):
2131
+ res = run_node(
2132
+ RPC_TUI_HARNESS
2133
+ + """
2134
+ const state = rpcState({}, "h0");
2135
+ const withRpc = { client: { rpc: (def) => rpcClient(state) } };
2136
+ const seen = [];
2137
+ const unsub = subscribeSettingsChanged(withRpc, (e) => seen.push(e));
2138
+ state.subs[0].fn({ hash: "hash9", keys: ["mode"] });
2139
+ if (typeof unsub === "function") unsub();
2140
+ const withoutRpc = subscribeSettingsChanged({ ui: {} }, () => {});
2141
+ console.log(JSON.stringify({
2142
+ subscribed: state.subs.map((s) => s.name),
2143
+ seen,
2144
+ unsubbed: state.unsubbed,
2145
+ bare: withoutRpc,
2146
+ pollMs: SETTINGS_POLL_MS,
2147
+ rpcId: SettingsRpc.id,
2148
+ }));
2149
+ """
2150
+ )
2151
+ self.assertEqual(res.returncode, 0, res.stderr)
2152
+ data = last_json_object(res.stdout)
2153
+ # Subscribed with the local event name; the received payload arrives.
2154
+ self.assertEqual(data["subscribed"], ["changed"])
2155
+ self.assertEqual(data["seen"], [{"hash": "hash9", "keys": ["mode"]}])
2156
+ self.assertTrue(data["unsubbed"])
2157
+ # No RPC -> null (the 5s mtime poll remains the floor).
2158
+ self.assertIsNone(data["bare"])
2159
+ self.assertEqual(data["pollMs"], 5000)
2160
+ self.assertEqual(data["rpcId"], "iumbtems.settings")
2161
+
2162
+
2163
+ class TestUiPrefsBoundary(unittest.TestCase):
2164
+ """C4 (TUI half): `storage.store` prefs never affect run-affecting values."""
2165
+
2166
+ def test_prefs_round_trip_without_touching_config(self):
2167
+ with tempfile.TemporaryDirectory() as tmp:
2168
+ research = Path(tmp) / ".research"
2169
+ research.mkdir()
2170
+ research.joinpath("config.json").write_text(
2171
+ json.dumps({"mode": "research"}), encoding="utf-8"
2172
+ )
2173
+ res = run_node(
2174
+ RPC_TUI_HARNESS
2175
+ + f"""
2176
+ const root = {tmp!r};
2177
+ const store = tuiStore();
2178
+ const host = {{
2179
+ directory: root, storage: store.storage,
2180
+ ui: {{ panel: async (p) => {{ host._panel = p; }} }},
2181
+ }};
2182
+ // Prefs deliberately named like run-affecting keys.
2183
+ const wrote = await writeUiPrefs(host, {{ mode: "audit", search_engine: "evil", lastSavedAt: "x" }});
2184
+ const prefs = readUiPrefs(host);
2185
+ const r = await openSettingsPanel(host, root, {{ env: {{}} }});
2186
+ const onDisk = JSON.parse(fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8"));
2187
+ console.log(JSON.stringify({{
2188
+ wrote, prefs, body: host._panel.body, onDisk,
2189
+ bare: readUiPrefs({{ ui: {{}} }}),
2190
+ }}));
2191
+ """
2192
+ )
2193
+ self.assertEqual(res.returncode, 0, f"prefs failed: {res.stderr}")
2194
+ data = last_json_object(res.stdout)
2195
+ self.assertTrue(data["wrote"])
2196
+ self.assertEqual(data["prefs"]["mode"], "audit")
2197
+ # Effective values still come from the file, never the prefs.
2198
+ self.assertIn("research", data["body"])
2199
+ self.assertNotIn("evil", data["body"])
2200
+ self.assertEqual(data["onDisk"], {"mode": "research"})
2201
+ # No TUI storage -> {} (never throws).
2202
+ self.assertEqual(data["bare"], {})
2203
+
2204
+
1750
2205
  if __name__ == "__main__":
1751
2206
  unittest.main()