@heretek-ai/epistemic-swarm 0.4.7 → 0.4.8

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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +59 -7
  3. package/config/mcp_launcher.py +75 -48
  4. package/extensions/pi/index.js +25 -5
  5. package/install.sh +5 -5
  6. package/package.json +1 -1
  7. package/plugins/antigravity/plugin.json +1 -1
  8. package/plugins/gemini/gemini-extension.json +1 -1
  9. package/plugins/opencode/index.js +308 -142
  10. package/plugins/opencode/tui.js +7 -9
  11. package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
  12. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
  13. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/auditor_engine.py +122 -102
  28. package/runner/claim_store.py +5 -4
  29. package/runner/living_dossiers.py +32 -10
  30. package/runner/mcp_protocol.py +1 -1
  31. package/runner/mcp_server.py +23 -11
  32. package/runner/path_safety.py +62 -0
  33. package/runner/pcrb.py +68 -39
  34. package/runner/pcrb_verify.py +82 -52
  35. package/runner/refinement.py +61 -47
  36. package/runner/research_swarm.py +61 -7
  37. package/runner/state_machine.py +9 -6
  38. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  52. package/runner/tests/test_mcp_server.py +0 -1
  53. package/runner/tests/test_opencode_ux.py +368 -33
  54. package/runner/tests/test_swarm.py +20 -9
  55. package/runner/tests/test_sweep_regressions.py +373 -0
  56. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  59. package/scripts/auction_experiment.py +38 -30
  60. package/scripts/bet1_advisory_spike.py +41 -25
  61. package/scripts/build_adapters.py +52 -37
  62. package/scripts/divergence_experiment.py +61 -31
  63. package/skills/brainstorming/scripts/brainstorm.py +3 -2
  64. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  65. package/skills/epistemic_search/scripts/fetch.py +0 -1
  66. package/skills/epistemic_search/scripts/search.py +202 -73
  67. package/skills/epistemic_search/scripts/webcache.py +1 -1
  68. package/skills/grilling/socratic_tree.py +26 -18
  69. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  70. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  71. package/skills/research_cache/hasher.py +27 -9
  72. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  73. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  74. package/skills/swarm_config/configure.py +32 -28
@@ -42,18 +42,47 @@ def last_json_object(stdout):
42
42
  return json.loads(lines[-1])
43
43
 
44
44
 
45
+ # Shared V2 mock: captures the tool map produced by `tool.transform` without a
46
+ # real host. The old tests drove `plugin.server()` — a V1 hook that no longer
47
+ # exists — so every tool assertion has to go through the V2 domain instead.
48
+ V2_TOOL_SETUP = """
49
+ import plugin from "./plugins/opencode/index.js";
50
+ const toolMap = {};
51
+ const host = {
52
+ options: {},
53
+ command: {
54
+ list: async () => ({data: []}),
55
+ transform: async () => ({dispose: () => {}}),
56
+ reload: async () => {}
57
+ },
58
+ tool: {
59
+ transform: async (fn) => {
60
+ fn({add: (t) => { toolMap[t.name] = t; }});
61
+ return {dispose: () => {}};
62
+ },
63
+ reload: async () => {}
64
+ },
65
+ session: { prompt: async () => ({}) }
66
+ };
67
+ await plugin.setup(host);
68
+ """
69
+
70
+
45
71
  class TestOpenCodeCommandCatalog(unittest.TestCase):
46
72
  def test_catalog_shape_and_registration(self):
73
+ # V2 only: the V1 `server()` config hook wrote the deprecated `command`
74
+ # key and registered nothing on a v2 host. The declarative catalog is
75
+ # now exposed as `commandCatalog()` (V2 key: `commands`); live hosts
76
+ # register through `command.transform` — covered in
77
+ # TestOpenCodeV2Transforms.
47
78
  res = run_node(
48
79
  """
49
- import plugin, { OPENCODE_COMMANDS, registerOpenCodeCommands } from "./plugins/opencode/index.js";
50
- const shell = await plugin.server();
51
- const cfg = {};
52
- shell.config(cfg);
80
+ import { OPENCODE_COMMANDS, commandCatalog } from "./plugins/opencode/index.js";
81
+ const catalog = commandCatalog();
53
82
  console.log(JSON.stringify({
54
83
  catalog: OPENCODE_COMMANDS.map(c => ({name: c.name, hasDesc: !!c.description, hasArgs: c.template.includes("$ARGUMENTS")})),
55
- registered: Object.keys(cfg.command || {}),
56
- templates: Object.fromEntries(Object.entries(cfg.command || {}).map(([k, v]) => [k, !!v.description && !!v.template]))
84
+ registered: Object.keys(catalog),
85
+ templates: Object.fromEntries(Object.entries(catalog).map(([k, v]) => [k, !!v.description && !!v.template]))
57
86
  }));
58
87
  """
59
88
  )
@@ -75,6 +104,25 @@ class TestOpenCodeCommandCatalog(unittest.TestCase):
75
104
  self.assertIn(c, data["registered"])
76
105
  self.assertTrue(data["templates"][c])
77
106
 
107
+ def test_catalog_uses_v2_commands_key(self):
108
+ """`command` is the deprecated V1 key and is ignored by a V2 host."""
109
+ res = run_node(
110
+ """
111
+ import plugin from "./plugins/opencode/index.js";
112
+ console.log(JSON.stringify({
113
+ hasServer: typeof plugin.server,
114
+ hasSetup: typeof plugin.setup,
115
+ hasConfigHook: false
116
+ }));
117
+ """
118
+ )
119
+ self.assertEqual(res.returncode, 0, f"entrypoint shape test failed: {res.stderr}")
120
+ data = last_json_object(res.stdout)
121
+ # V1 `server()` is gone: a v2 host never calls it, and keeping it meant
122
+ # the real registration path had to guess which host it was talking to.
123
+ self.assertNotEqual(data["hasServer"], "function")
124
+ self.assertEqual(data["hasSetup"], "function")
125
+
78
126
  def test_empty_arg_guards(self):
79
127
  res = run_node(
80
128
  """
@@ -91,43 +139,64 @@ class TestOpenCodeCommandCatalog(unittest.TestCase):
91
139
  self.assertEqual(len(data["withUsage"]), data["total"])
92
140
 
93
141
  def test_async_execute_resolves(self):
142
+ # V2 tool results are `{content}` only (the V1 map added a `status`
143
+ # field, which the V2 domain does not have).
94
144
  res = run_node(
95
- """
96
- import plugin from "./plugins/opencode/index.js";
97
- const shell = await plugin.server();
145
+ V2_TOOL_SETUP
146
+ + """
98
147
  let ticked = false;
99
148
  const timer = setInterval(() => { ticked = true; }, 5);
100
- const r = await shell.tool.iumbtems_config.execute({});
149
+ const r = await toolMap.iumbtems_config.execute({});
101
150
  clearInterval(timer);
102
- console.log(JSON.stringify({status: r.status, hasContent: typeof r.content === "string" && r.content.length > 0, loopAlive: ticked}));
151
+ console.log(JSON.stringify({
152
+ hasContent: typeof r.content === "string" && r.content.length > 0,
153
+ shapeOk: Object.keys(r).sort().join(",") === "content",
154
+ loopAlive: ticked
155
+ }));
103
156
  """
104
157
  )
105
158
  self.assertEqual(res.returncode, 0, f"async execute test failed: {res.stderr}")
106
159
  data = last_json_object(res.stdout)
107
- self.assertEqual(data["status"], "success")
108
160
  self.assertTrue(data["hasContent"])
161
+ self.assertTrue(data["shapeOk"])
109
162
  self.assertTrue(data["loopAlive"])
110
163
 
111
- def test_registration_never_overwrites(self):
164
+ def test_v2_skips_preexisting_command_names(self):
165
+ """Names already present at setup time are respected, not overwritten.
166
+
167
+ This is the dedupe half of the replay fix: the skip set must be a
168
+ snapshot taken at setup, not a set that grows across transform replays.
169
+ """
112
170
  res = run_node(
113
171
  """
114
- import { registerOpenCodeCommands } from "./plugins/opencode/index.js";
115
- const cfg = {command: {swarm: {description: "mine", template: "mine"}}};
116
- registerOpenCodeCommands(cfg);
117
- console.log(JSON.stringify({swarm: cfg.command.swarm, count: Object.keys(cfg.command).length}));
172
+ import plugin from "./plugins/opencode/index.js";
173
+ const added = [];
174
+ const host = {
175
+ options: {},
176
+ command: {
177
+ list: async () => ({data: [{name: "swarm"}]}),
178
+ transform: async (fn) => { fn({add: (d) => added.push(d)}); return {dispose: () => {}}; },
179
+ reload: async () => {}
180
+ },
181
+ tool: { transform: async () => ({dispose: () => {}}) },
182
+ session: { prompt: async () => ({}) }
183
+ };
184
+ await plugin.setup(host);
185
+ console.log(JSON.stringify({names: added.map(c => c.name)}));
118
186
  """
119
187
  )
120
- self.assertEqual(res.returncode, 0, f"no-overwrite test failed: {res.stderr}")
188
+ self.assertEqual(res.returncode, 0, f"preexisting-name test failed: {res.stderr}")
121
189
  data = last_json_object(res.stdout)
122
- self.assertEqual(data["swarm"], {"description": "mine", "template": "mine"})
123
- self.assertEqual(data["count"], len(EXPECTED_COMMANDS))
190
+ # `swarm` was already registered by the user, so we add the other six.
191
+ self.assertNotIn("swarm", data["names"])
192
+ self.assertEqual(len(data["names"]), len(EXPECTED_COMMANDS) - 1)
124
193
 
125
194
  def test_tool_map_shape(self):
126
195
  res = run_node(
127
- """
128
- import plugin, { IUMBTEMS_TOOL_NAMES } from "./plugins/opencode/index.js";
129
- const shell = await plugin.server();
130
- const entries = Object.entries(shell.tool);
196
+ V2_TOOL_SETUP
197
+ + """
198
+ import { IUMBTEMS_TOOL_NAMES } from "./plugins/opencode/index.js";
199
+ const entries = Object.entries(toolMap);
131
200
  console.log(JSON.stringify({
132
201
  keys: entries.map(([k]) => k),
133
202
  canonical: IUMBTEMS_TOOL_NAMES,
@@ -416,18 +485,36 @@ class TestOpenCodeLifecycle(unittest.TestCase):
416
485
  self.assertFalse(data["empty"])
417
486
 
418
487
  def test_compaction_hook_pushes_state(self):
488
+ # V2 shape: `session.hook("compaction", fn)` pushes a text part onto
489
+ # `event.system`. The old `experimental.session.compacting` hook lived
490
+ # in the V1 `server()` factory and was dead code on a V2 host.
419
491
  with tempfile.TemporaryDirectory() as tmp:
420
492
  root = self._fixture(tmp)
421
493
  res = run_node(
422
494
  f"""
423
495
  import plugin from "./plugins/opencode/index.js";
424
- const shell = await plugin.server();
425
- const hook = shell["experimental.session.compacting"];
426
- const out = {{context: []}};
427
- await hook({{directory: {root!r}}}, out);
428
- const empty = {{context: []}};
429
- await hook({{directory: "/tmp/oc-lifecycle-empty"}}, empty);
430
- console.log(JSON.stringify({{hook: typeof hook, pushed: out.context.length, text: out.context[0] || "", skipped: empty.context.length}}));
496
+ let handler = null;
497
+ const host = {{
498
+ options: {{}},
499
+ location: {{directory: {root!r}}},
500
+ command: {{list: async () => ({{data: []}}), transform: async () => ({{dispose: async () => {{}}}}), reload: async () => {{}}}},
501
+ tool: {{transform: async () => ({{dispose: async () => {{}}}}), reload: async () => {{}}}},
502
+ session: {{
503
+ prompt: async () => ({{}}),
504
+ hook: async (name, fn) => {{ if (name === "compaction") handler = fn; return {{dispose: async () => {{}}}}; }}
505
+ }}
506
+ }};
507
+ await plugin.setup(host);
508
+ const out = {{system: [], directory: {root!r}}};
509
+ if (handler) await handler(out);
510
+ const empty = {{system: [], directory: "/tmp/oc-lifecycle-empty-nonexistent"}};
511
+ if (handler) await handler(empty);
512
+ console.log(JSON.stringify({{
513
+ hook: typeof handler,
514
+ pushed: out.system.length,
515
+ text: (out.system[0] && out.system[0].text) || "",
516
+ skipped: empty.system.length
517
+ }}));
431
518
  """
432
519
  )
433
520
  self.assertEqual(res.returncode, 0, f"compaction test failed: {res.stderr}")
@@ -503,7 +590,7 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
503
590
  )
504
591
  self.assertEqual(res.returncode, 0, f"v2 transform test failed: {res.stderr}")
505
592
  data = last_json_object(res.stdout)
506
- self.assertEqual(data["cleanupFn"], True)
593
+ self.assertTrue(data["cleanupFn"])
507
594
  for c in [
508
595
  "swarm",
509
596
  "grill",
@@ -563,7 +650,7 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
563
650
  )
564
651
  self.assertEqual(res.returncode, 0, f"bare setup test failed: {res.stderr}")
565
652
  data = last_json_object(res.stdout)
566
- self.assertEqual(data["cleanupFn"], True)
653
+ self.assertTrue(data["cleanupFn"])
567
654
 
568
655
  def test_options_applied_to_location_dir_after_transforms(self):
569
656
  import tempfile
@@ -618,6 +705,254 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
618
705
  self.assertTrue(data["cleanupFn"])
619
706
  self.assertTrue(data["aborted"])
620
707
 
708
+ # ------------------------------------------------------------------
709
+ # Regression tests for the vanishing-slash-commands defect.
710
+ #
711
+ # The host REPLAYS a command transform callback on every registry rebuild.
712
+ # The old code kept one `existing` Set closed over from setup and mutated it
713
+ # inside the callback, so replay 2 saw every name as "already added" and
714
+ # registered nothing — commands appeared at startup and vanished seconds
715
+ # later. The mock used to invoke the callback exactly once, so the suite was
716
+ # structurally blind to it.
717
+ # ------------------------------------------------------------------
718
+
719
+ REPLAY_SETUP = """
720
+ import plugin from "./plugins/opencode/index.js";
721
+ const passes = [];
722
+ let captured = null;
723
+ const host = {
724
+ options: {},
725
+ command: {
726
+ // Emulate a host whose registry is rebuilt from base config on
727
+ // each pass: our commands are NOT pre-existing at setup time,
728
+ // and are wiped before every replay.
729
+ list: async () => ({data: [{name: "goal"}]}),
730
+ transform: async (fn) => { captured = fn; return {dispose: () => {}}; },
731
+ reload: async () => {}
732
+ },
733
+ tool: {
734
+ transform: async () => ({dispose: () => {}}),
735
+ reload: async () => {}
736
+ },
737
+ session: { prompt: async () => ({}) }
738
+ };
739
+ const cleanup = await plugin.setup(host);
740
+ for (let i = 0; i < 3; i++) {
741
+ const draftAdds = [];
742
+ captured({add: (d) => draftAdds.push(d)});
743
+ passes.push(draftAdds.map((d) => d.name));
744
+ }
745
+ """
746
+
747
+ def test_v2_command_transform_is_replay_safe(self):
748
+ """Every replay must re-add all commands. This is the vanish bug."""
749
+ res = run_node(
750
+ self.REPLAY_SETUP
751
+ + """
752
+ console.log(JSON.stringify({passes}));
753
+ """
754
+ )
755
+ self.assertEqual(res.returncode, 0, f"replay test failed: {res.stderr}")
756
+ passes = last_json_object(res.stdout)["passes"]
757
+ self.assertEqual(len(passes), 3)
758
+ for i, added in enumerate(passes):
759
+ self.assertEqual(
760
+ len(added),
761
+ len(EXPECTED_COMMANDS),
762
+ f"transform pass {i} added {len(added)} commands, expected "
763
+ f"{len(EXPECTED_COMMANDS)} — a replay must not skip our names "
764
+ f"({added})",
765
+ )
766
+ for c in EXPECTED_COMMANDS:
767
+ self.assertIn(c, added)
768
+
769
+ def test_v2_setup_return_is_a_function(self):
770
+ """Returning a plain object aborts host reload: `de is not a function`."""
771
+ res = run_node(
772
+ REPLAY_SINGLE
773
+ + """
774
+ console.log(JSON.stringify({type: typeof cleanup}));
775
+ """
776
+ )
777
+ self.assertEqual(res.returncode, 0, f"setup-return test failed: {res.stderr}")
778
+ self.assertEqual(last_json_object(res.stdout)["type"], "function")
779
+
780
+ def test_v2_cleanup_disposes_registrations(self):
781
+ res = run_node(
782
+ """
783
+ import plugin from "./plugins/opencode/index.js";
784
+ const disposed = [];
785
+ const mk = (label) => ({dispose: async () => { disposed.push(label); }});
786
+ const host = {
787
+ options: {},
788
+ command: {
789
+ list: async () => ({data: []}),
790
+ transform: async () => mk("command"),
791
+ reload: async () => {}
792
+ },
793
+ tool: {
794
+ transform: async () => mk("tool"),
795
+ reload: async () => {}
796
+ },
797
+ session: {
798
+ prompt: async () => ({}),
799
+ hook: async () => mk("compaction")
800
+ }
801
+ };
802
+ const cleanup = await plugin.setup(host);
803
+ await cleanup();
804
+ console.log(JSON.stringify({disposed}));
805
+ """
806
+ )
807
+ self.assertEqual(res.returncode, 0, f"dispose test failed: {res.stderr}")
808
+ disposed = last_json_object(res.stdout)["disposed"]
809
+ # Every transform/hook handle must be disposed on teardown, otherwise a
810
+ # stale registration can make command.list() report names with no live
811
+ # handler — which is what makes the skip-check fire on the next setup.
812
+ self.assertIn("command", disposed)
813
+ self.assertIn("tool", disposed)
814
+ self.assertIn("compaction", disposed)
815
+
816
+ def test_v2_repeated_setup_cycles_register_both_times(self):
817
+ """setup -> cleanup -> setup must re-register (the reload path)."""
818
+ res = run_node(
819
+ """
820
+ import plugin from "./plugins/opencode/index.js";
821
+ const perSetup = [];
822
+ for (let cycle = 0; cycle < 2; cycle++) {
823
+ const added = [];
824
+ let captured = null;
825
+ const host = {
826
+ options: {},
827
+ command: {
828
+ list: async () => ({data: []}),
829
+ transform: async (fn) => { captured = fn; return {dispose: async () => {}}; },
830
+ reload: async () => {}
831
+ },
832
+ tool: { transform: async () => ({dispose: async () => {}}), reload: async () => {} },
833
+ session: { prompt: async () => ({}) }
834
+ };
835
+ const cleanup = await plugin.setup(host);
836
+ captured({add: (d) => added.push(d.name)});
837
+ perSetup.push(added);
838
+ await cleanup();
839
+ }
840
+ console.log(JSON.stringify({perSetup}));
841
+ """
842
+ )
843
+ self.assertEqual(res.returncode, 0, f"setup-cycle test failed: {res.stderr}")
844
+ per_setup = last_json_object(res.stdout)["perSetup"]
845
+ self.assertEqual(len(per_setup), 2)
846
+ for i, added in enumerate(per_setup):
847
+ self.assertEqual(
848
+ len(added), len(EXPECTED_COMMANDS),
849
+ f"setup cycle {i} registered {len(added)} commands",
850
+ )
851
+
852
+ def test_v2_compaction_hook_preserves_state(self):
853
+ """The V1 `experimental.session.compacting` hook was dead code on V2.
854
+
855
+ Uses a real fixture workspace: `buildCompactionContext` returns null
856
+ without `.research/config.json`, and pointing at `process.cwd()` made
857
+ this pass only where a developer happened to have one.
858
+ """
859
+ with tempfile.TemporaryDirectory() as tmp:
860
+ root = Path(tmp)
861
+ rd = root / ".research"
862
+ rd.mkdir()
863
+ (rd / "config.json").write_text(
864
+ json.dumps({"mode": "audit", "search_engine": "duckduckgo", "max_iterations": 2}),
865
+ encoding="utf-8",
866
+ )
867
+ res = run_node(
868
+ f"""
869
+ import plugin from "./plugins/opencode/index.js";
870
+ let hookName = null;
871
+ let handler = null;
872
+ const host = {{
873
+ options: {{}},
874
+ location: {{directory: {str(root)!r}}},
875
+ command: {{list: async () => ({{data: []}}), transform: async () => ({{dispose: async () => {{}}}}), reload: async () => {{}}}},
876
+ tool: {{transform: async () => ({{dispose: async () => {{}}}}), reload: async () => {{}}}},
877
+ session: {{
878
+ prompt: async () => ({{}}),
879
+ hook: async (name, fn) => {{ hookName = name; handler = fn; return {{dispose: async () => {{}}}}; }}
880
+ }}
881
+ }};
882
+ await plugin.setup(host);
883
+ const event = {{system: [], directory: {str(root)!r}}};
884
+ if (handler) await handler(event);
885
+ const emptyEvent = {{system: [], directory: "/tmp/oc-v2-compaction-empty-nonexistent"}};
886
+ if (handler) await handler(emptyEvent);
887
+ console.log(JSON.stringify({{
888
+ hookName,
889
+ parts: event.system.map((p) => ({{type: p.type, text: p.text || ""}})),
890
+ emptySkipped: emptyEvent.system.length
891
+ }}));
892
+ """
893
+ )
894
+ self.assertEqual(res.returncode, 0, f"compaction hook test failed: {res.stderr}")
895
+ data = last_json_object(res.stdout)
896
+ self.assertEqual(data["hookName"], "compaction")
897
+ self.assertEqual(data["emptySkipped"], 0, "empty workspace must not push state")
898
+ self.assertTrue(
899
+ data["parts"]
900
+ and data["parts"][0]["type"] == "text"
901
+ and "mode=audit" in data["parts"][0]["text"],
902
+ f"compaction hook did not push state onto event.system: {data}",
903
+ )
904
+
905
+ def test_v2_logs_never_throw_and_surface_errors(self):
906
+ """Bare `catch {}` made failures invisible under --log-level all."""
907
+ res = run_node(
908
+ """
909
+ import plugin from "./plugins/opencode/index.js";
910
+ const logged = [];
911
+ const origError = console.error;
912
+ console.error = (...a) => { logged.push(a.join(" ")); };
913
+ const host = {
914
+ options: {},
915
+ command: {
916
+ list: async () => { throw new Error("registry unavailable"); },
917
+ transform: async (fn) => { fn({add: () => {}}); return {dispose: async () => {}}; },
918
+ reload: async () => {}
919
+ },
920
+ tool: {transform: async () => ({dispose: async () => {}}), reload: async () => {}},
921
+ session: {prompt: async () => ({})}
922
+ };
923
+ const cleanup = await plugin.setup(host);
924
+ await cleanup();
925
+ console.error = origError;
926
+ console.log(JSON.stringify({
927
+ logged,
928
+ mentionsFailure: logged.some((l) => /command\\.list|registry unavailable/.test(l))
929
+ }));
930
+ """
931
+ )
932
+ self.assertEqual(res.returncode, 0, f"logging test failed: {res.stderr}")
933
+ data = last_json_object(res.stdout)
934
+ self.assertTrue(
935
+ data["mentionsFailure"],
936
+ f"a swallowed error must be logged, not dropped: {data['logged']}",
937
+ )
938
+
939
+
940
+ # One-shot REPLAY_SETUP equivalent that also yields the cleanup function.
941
+ REPLAY_SINGLE = """
942
+ import plugin from "./plugins/opencode/index.js";
943
+ const host = {
944
+ options: {},
945
+ command: {
946
+ list: async () => ({data: []}),
947
+ transform: async () => ({dispose: async () => {}}),
948
+ reload: async () => {}
949
+ },
950
+ tool: {transform: async () => ({dispose: async () => {}}), reload: async () => {}},
951
+ session: {prompt: async () => ({})}
952
+ };
953
+ const cleanup = await plugin.setup(host);
954
+ """
955
+
621
956
 
622
957
  if __name__ == "__main__":
623
958
  unittest.main()
@@ -41,7 +41,7 @@ In our experiments, the 70B parameter model was trained on 15.0 trillion tokens.
41
41
  content=content,
42
42
  title="Chinchilla Scaling Laws",
43
43
  )
44
- self.assertTrue(len(shash) == 64)
44
+ self.assertEqual(len(shash), 64)
45
45
 
46
46
  # 1. Exact quote match
47
47
  verified, conf, msg = self.hasher.verify_quote(
@@ -319,19 +319,30 @@ In our experiments, the 70B parameter model was trained on 15.0 trillion tokens.
319
319
  # 1. Test OpenCode Plugin registration (A2b: thin forwarders + both
320
320
  # name exports — canonical IUMBTEMS_TOOL_NAMES and the deprecated
321
321
  # IUMBEMS_TOOL_NAMES alias). Object form: server() resolves to
322
- # {config, tool} with the tool map keyed by name.
322
+ # V2 shape: tools + commands are registered through the owning
323
+ # domains inside setup(); the declarative catalog is commandCatalog().
324
+ # (The V1 `server()` config hook is gone — it wrote the deprecated
325
+ # `command` key and registered nothing on a v2 host.)
323
326
  node_code_oc = """
324
- import plugin, { IUMBTEMS_TOOL_NAMES, IUMBEMS_TOOL_NAMES } from "./plugins/opencode/index.js";
325
- const shell = await plugin.server();
326
- const tools = Object.values(shell.tool);
327
+ import plugin, { IUMBTEMS_TOOL_NAMES, IUMBEMS_TOOL_NAMES, commandCatalog } from "./plugins/opencode/index.js";
328
+ const toolMap = {};
329
+ const host = {
330
+ options: {},
331
+ command: {list: async () => ({data: []}), transform: async () => ({dispose: async () => {}}), reload: async () => {}},
332
+ tool: {
333
+ transform: async (fn) => { fn({add: (t) => { toolMap[t.name] = t; }}); return {dispose: async () => {}}; },
334
+ reload: async () => {}
335
+ },
336
+ session: {prompt: async () => ({})}
337
+ };
338
+ await plugin.setup(host);
339
+ const tools = Object.values(toolMap);
327
340
  const names = tools.map(t => t.name);
328
- const cfg = {};
329
- shell.config(cfg);
330
341
  console.log(JSON.stringify({
331
342
  names,
332
343
  canonical: IUMBTEMS_TOOL_NAMES,
333
344
  alias: IUMBEMS_TOOL_NAMES,
334
- commands: Object.keys(cfg.command || {})
345
+ commands: Object.keys(commandCatalog())
335
346
  }));
336
347
  """
337
348
  res_oc = subprocess.run(
@@ -370,7 +381,7 @@ In our experiments, the 70B parameter model was trained on 15.0 trillion tokens.
370
381
  ]
371
382
  for t in expected_tools:
372
383
  self.assertIn(t, tools)
373
- # Slash-command catalog registered via the server config hook.
384
+ # Slash-command catalog (V2 `commands` shape).
374
385
  for c in [
375
386
  "swarm",
376
387
  "grill",