@heretek-ai/epistemic-swarm 0.7.21 → 0.7.23

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 (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli.js +3 -2
  4. package/extensions/pi/index.js +3 -2
  5. package/package.json +5 -1
  6. package/plugins/antigravity/plugin.json +1 -1
  7. package/plugins/factory/skills/factory/SKILL.md +1 -1
  8. package/plugins/opencode/analysis-bridge.js +566 -0
  9. package/plugins/opencode/config-io.js +598 -31
  10. package/plugins/opencode/dep-health.js +1039 -0
  11. package/plugins/opencode/hook-bus.js +434 -0
  12. package/plugins/opencode/index.js +252 -19
  13. package/plugins/opencode/tui.js +230 -17
  14. package/plugins/opencode/weight-baseline.json +16 -0
  15. package/plugins/opencode/weight-signals.js +769 -0
  16. package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
  17. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  29. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  30. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  31. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  32. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  33. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  34. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  35. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  36. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  37. package/runner/mcp_server.py +249 -81
  38. package/runner/refinement.py +5 -0
  39. package/runner/schema_validate.py +63 -1
  40. package/runner/schemas.py +9 -4
  41. package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  63. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  64. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  65. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  66. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  67. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  68. package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
  69. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  70. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  71. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  72. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  73. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  74. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  75. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  76. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  77. package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
  78. package/runner/tests/fixtures/config_validation_cases.json +60 -0
  79. package/runner/tests/test_analysis_bridge.py +356 -0
  80. package/runner/tests/test_config_schema.py +119 -15
  81. package/runner/tests/test_dep_health.py +484 -0
  82. package/runner/tests/test_hardening_contract.py +391 -0
  83. package/runner/tests/test_hook_bus.py +802 -0
  84. package/runner/tests/test_opencode_ux.py +232 -0
  85. package/runner/tests/test_opencode_v2_registrars.py +548 -16
  86. package/runner/tests/test_phase08_waiver_reopens.py +539 -0
  87. package/runner/tests/test_weight_signals.py +361 -0
  88. package/schemas/config.schema.json +4 -3
  89. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  90. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  91. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  92. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  93. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  94. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  95. package/skills/factory/SKILL.md +1 -1
  96. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  97. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  98. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  99. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  100. package/skills/research_cache/hasher.py +33 -8
  101. package/skills/swarm_config/SKILL.md +6 -1
  102. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  103. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  104. package/skills/swarm_config/configure.py +254 -17
@@ -0,0 +1,802 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for Phase 01-hook-bus-spec: six-event blockable hook bus.
3
+
4
+ Mock-host only (no live TUI): bus unit tests (order, fast/slow timeouts,
5
+ fail-open, audit completeness, all-six-events blockable), the pre-commit gate
6
+ on the settings-RPC write path (stale-write preservation), registrar-adopt
7
+ (bus adopted + disposed alongside the existing four), and parity-doc pointer
8
+ resolution.
9
+
10
+ Phase evidence cited in-code per brief:
11
+ [VERIFIED: sha256:52ac5b4d26062cfc6093440faa415ab152f8acd0d18c4496eb0087ed18a27d76]
12
+ [VERIFIED: sha256:06fff10d7c9d77a86c42d6f412b22de46a6f6bba89dea23a863075bdd3d98bda]
13
+ Gate-0 parents: 08229740d0a7f752b0c388876ec1588e513f2b3bc8130a72771bee9593d92665,
14
+ 131bcb343879bfadcba9a40719ddd0b77540eb21f4cdc937d18b1b2943579afa.
15
+ """
16
+
17
+ import json
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import unittest
22
+ from pathlib import Path
23
+
24
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
25
+ if str(PROJECT_ROOT) not in sys.path:
26
+ sys.path.insert(0, str(PROJECT_ROOT))
27
+
28
+ SIX_EVENTS = [
29
+ "pre-tool-use",
30
+ "post-tool-use",
31
+ "pre-commit",
32
+ "stop",
33
+ "notification",
34
+ "session-start",
35
+ ]
36
+
37
+
38
+ def run_node(code):
39
+ return subprocess.run(
40
+ ["node", "--input-type=module", "-e", code],
41
+ capture_output=True,
42
+ text=True,
43
+ cwd=str(PROJECT_ROOT),
44
+ )
45
+
46
+
47
+ def last_json_object(stdout):
48
+ lines = [l.strip() for l in stdout.strip().split("\n") if l.strip().startswith("{")]
49
+ return json.loads(lines[-1])
50
+
51
+
52
+ BUS_IMPORT = 'import { createHookBus } from "./plugins/opencode/hook-bus.js";\n'
53
+
54
+
55
+ class TestHookBusSixDenyBlocks(unittest.TestCase):
56
+ def test_each_event_deny_blocks(self):
57
+ # Functional: every one of the six events has a deny-blocks path.
58
+ res = run_node(
59
+ BUS_IMPORT
60
+ + f"""
61
+ const results = [];
62
+ for (const event of {json.dumps(SIX_EVENTS)}) {{
63
+ const bus = createHookBus();
64
+ bus.on(event, () => "deny", {{tier: "fast"}});
65
+ const r = await bus.emit(event, {{tool: "webfetch", eventId: "e1"}});
66
+ results.push({{event, allowed: r.allowed}});
67
+ }}
68
+ console.log(JSON.stringify({{results}}));
69
+ """
70
+ )
71
+ self.assertEqual(res.returncode, 0, f"deny-blocks test failed: {res.stderr}")
72
+ for row in last_json_object(res.stdout)["results"]:
73
+ self.assertFalse(row["allowed"], f"{row['event']} deny did not block")
74
+
75
+ def test_allow_passes(self):
76
+ res = run_node(
77
+ BUS_IMPORT
78
+ + """
79
+ const bus = createHookBus();
80
+ bus.on("pre-tool-use", () => "allow", {tier: "fast"});
81
+ const r = await bus.emit("pre-tool-use", {tool: "webfetch"});
82
+ console.log(JSON.stringify({allowed: r.allowed}));
83
+ """
84
+ )
85
+ self.assertEqual(res.returncode, 0, f"allow test failed: {res.stderr}")
86
+ self.assertTrue(last_json_object(res.stdout)["allowed"])
87
+
88
+ def test_slow_deny_inside_budget_blocks(self):
89
+ res = run_node(
90
+ BUS_IMPORT
91
+ + """
92
+ const bus = createHookBus();
93
+ bus.on("stop", async () => {
94
+ await new Promise((r) => setTimeout(r, 200));
95
+ return "deny";
96
+ }, {tier: "slow"});
97
+ const r = await bus.emit("stop", {});
98
+ console.log(JSON.stringify({allowed: r.allowed}));
99
+ """
100
+ )
101
+ self.assertEqual(res.returncode, 0, f"slow-deny test failed: {res.stderr}")
102
+ self.assertFalse(last_json_object(res.stdout)["allowed"])
103
+
104
+
105
+ class TestHookBusTimeouts(unittest.TestCase):
106
+ def test_slow_overrun_degrades_to_advisory(self):
107
+ # Slow handler exceeding the slow budget: fail-open allow + advisory
108
+ # annotation + timeout audit entry (never bricks, never throws).
109
+ res = run_node(
110
+ BUS_IMPORT
111
+ + """
112
+ const bus = createHookBus();
113
+ bus.on("pre-tool-use", async () => {
114
+ await new Promise((r) => setTimeout(r, 300));
115
+ return "deny";
116
+ }, {tier: "slow"});
117
+ const r = await bus.emit("pre-tool-use", {tool: "webfetch"}, {slowTimeoutMs: 50});
118
+ const audit = bus.getAuditLog();
119
+ console.log(JSON.stringify({
120
+ allowed: r.allowed,
121
+ annotations: r.annotations.length,
122
+ outcomes: audit.map((a) => a.outcome)
123
+ }));
124
+ """
125
+ )
126
+ self.assertEqual(res.returncode, 0, f"slow-overrun test failed: {res.stderr}")
127
+ data = last_json_object(res.stdout)
128
+ self.assertTrue(data["allowed"], "slow overrun must fail open")
129
+ self.assertGreaterEqual(data["annotations"], 1)
130
+ self.assertIn("timeout", data["outcomes"])
131
+
132
+ def test_slow_overrun_does_not_brick_fast_path(self):
133
+ # A slow deny landing inside the slow budget still blocks, while the
134
+ # fast handler's verdict is recorded independently (tiers concurrent).
135
+ res = run_node(
136
+ BUS_IMPORT
137
+ + """
138
+ const bus = createHookBus();
139
+ const order = [];
140
+ bus.on("stop", () => { order.push("fast"); return "allow"; }, {tier: "fast"});
141
+ bus.on("stop", async () => {
142
+ await new Promise((r) => setTimeout(r, 800));
143
+ order.push("slow");
144
+ return "deny";
145
+ }, {tier: "slow"});
146
+ const t0 = Date.now();
147
+ const r = await bus.emit("stop", {});
148
+ const elapsed = Date.now() - t0;
149
+ const audit = bus.getAuditLog();
150
+ const fastEntry = audit.find((a) => a.tier === "fast");
151
+ console.log(JSON.stringify({
152
+ allowed: r.allowed, order, elapsed,
153
+ fastLatency: fastEntry && fastEntry.latencyMs
154
+ }));
155
+ """
156
+ )
157
+ self.assertEqual(res.returncode, 0, f"fast-path test failed: {res.stderr}")
158
+ data = last_json_object(res.stdout)
159
+ self.assertFalse(data["allowed"], "slow deny inside budget must block")
160
+ self.assertIn("fast", data["order"])
161
+ # The fast verdict landed on the fast path, not after the slow 800 ms.
162
+ self.assertLess(data["fastLatency"], 800)
163
+
164
+ def test_fast_timeout_fails_open(self):
165
+ res = run_node(
166
+ BUS_IMPORT
167
+ + """
168
+ const bus = createHookBus();
169
+ bus.on("notification", async () => {
170
+ await new Promise((r) => setTimeout(r, 300));
171
+ return "deny";
172
+ }, {tier: "fast"});
173
+ const r = await bus.emit("notification", {}, {fastTimeoutMs: 50});
174
+ console.log(JSON.stringify({allowed: r.allowed}));
175
+ """
176
+ )
177
+ self.assertEqual(res.returncode, 0, f"fast-timeout test failed: {res.stderr}")
178
+ self.assertTrue(last_json_object(res.stdout)["allowed"])
179
+
180
+
181
+ class TestHookBusFailOpen(unittest.TestCase):
182
+ def test_throwing_handler_fails_open_with_audit(self):
183
+ res = run_node(
184
+ BUS_IMPORT
185
+ + """
186
+ const bus = createHookBus();
187
+ bus.on("pre-tool-use", () => { throw new Error("boom"); }, {tier: "fast"});
188
+ let threw = false;
189
+ let r = null;
190
+ try {
191
+ r = await bus.emit("pre-tool-use", {tool: "webfetch", eventId: "e9"});
192
+ } catch { threw = true; }
193
+ const audit = bus.getAuditLog();
194
+ console.log(JSON.stringify({
195
+ threw, allowed: r && r.allowed, outcomes: audit.map((a) => a.outcome)
196
+ }));
197
+ """
198
+ )
199
+ self.assertEqual(res.returncode, 0, f"throw test failed: {res.stderr}")
200
+ data = last_json_object(res.stdout)
201
+ self.assertFalse(data["threw"], "emit must never throw into the host")
202
+ self.assertTrue(data["allowed"])
203
+ self.assertIn("error", data["outcomes"])
204
+
205
+ def test_rejecting_handler_fails_open(self):
206
+ res = run_node(
207
+ BUS_IMPORT
208
+ + """
209
+ const bus = createHookBus();
210
+ bus.on("stop", async () => { throw new Error("async boom"); }, {tier: "slow"});
211
+ const r = await bus.emit("stop", {});
212
+ console.log(JSON.stringify({allowed: r.allowed}));
213
+ """
214
+ )
215
+ self.assertEqual(res.returncode, 0, f"reject test failed: {res.stderr}")
216
+ self.assertTrue(last_json_object(res.stdout)["allowed"])
217
+
218
+ def test_unknown_event_fails_open(self):
219
+ res = run_node(
220
+ BUS_IMPORT
221
+ + """
222
+ const bus = createHookBus();
223
+ const r = await bus.emit("not-an-event", {});
224
+ const badOn = bus.on("not-an-event", () => "deny");
225
+ const r2 = await bus.emit("not-an-event", {});
226
+ console.log(JSON.stringify({
227
+ allowed: r.allowed, allowed2: r2.allowed,
228
+ unsub: typeof badOn
229
+ }));
230
+ """
231
+ )
232
+ self.assertEqual(res.returncode, 0, f"unknown-event test failed: {res.stderr}")
233
+ data = last_json_object(res.stdout)
234
+ self.assertTrue(data["allowed"])
235
+ self.assertTrue(data["allowed2"])
236
+ self.assertEqual(data["unsub"], "function")
237
+
238
+
239
+ class TestHookBusOrderAndAudit(unittest.TestCase):
240
+ def test_registration_order_preserved(self):
241
+ # Verdicts report in registration order; the audit log carries the
242
+ # same handler multiset (tiers run concurrently, so completion order
243
+ # may interleave — each entry's seq preserves the total order).
244
+ res = run_node(
245
+ BUS_IMPORT
246
+ + """
247
+ const bus = createHookBus();
248
+ bus.on("post-tool-use", () => "allow", {tier: "fast", name: "first"});
249
+ bus.on("post-tool-use", () => "allow", {tier: "fast", name: "second"});
250
+ bus.on("post-tool-use", () => "allow", {tier: "slow", name: "third"});
251
+ const r = await bus.emit("post-tool-use", {});
252
+ console.log(JSON.stringify({
253
+ verdicts: r.verdicts.map((v) => v.handler),
254
+ audit: bus.getAuditLog().map((a) => a.handler)
255
+ }));
256
+ """
257
+ )
258
+ self.assertEqual(res.returncode, 0, f"order test failed: {res.stderr}")
259
+ data = last_json_object(res.stdout)
260
+ self.assertEqual(data["verdicts"], ["first", "second", "third"])
261
+ self.assertEqual(sorted(data["audit"]), ["first", "second", "third"])
262
+
263
+ def test_audit_completeness(self):
264
+ # Every decision audit-logs verdict + outcome + latency + tool/event id.
265
+ res = run_node(
266
+ BUS_IMPORT
267
+ + """
268
+ const bus = createHookBus();
269
+ bus.on("pre-tool-use", () => "deny", {tier: "fast", name: "gate"});
270
+ bus.on("pre-tool-use", async () => {
271
+ await new Promise((r) => setTimeout(r, 200));
272
+ return "allow";
273
+ }, {tier: "slow", name: "checker"});
274
+ await bus.emit("pre-tool-use", {tool: "webfetch", eventId: "e7"});
275
+ const audit = bus.getAuditLog();
276
+ const keys = audit.map((a) => Object.keys(a).sort());
277
+ console.log(JSON.stringify({audit, keys}));
278
+ """
279
+ )
280
+ self.assertEqual(res.returncode, 0, f"audit test failed: {res.stderr}")
281
+ data = last_json_object(res.stdout)
282
+ self.assertEqual(len(data["audit"]), 2)
283
+ required = {
284
+ "event",
285
+ "tier",
286
+ "handler",
287
+ "verdict",
288
+ "outcome",
289
+ "latencyMs",
290
+ "tool",
291
+ "eventId",
292
+ }
293
+ for entry_keys in data["keys"]:
294
+ self.assertTrue(
295
+ required.issubset(set(entry_keys)),
296
+ f"audit entry missing keys: {required - set(entry_keys)}",
297
+ )
298
+ outcomes = {a["outcome"] for a in data["audit"]}
299
+ self.assertIn("deny", outcomes)
300
+ self.assertIn("allow", outcomes)
301
+
302
+
303
+ class TestPreCommitGate(unittest.TestCase):
304
+ GATE_SETUP = """
305
+ import { createSettingsHandlers } from "./plugins/opencode/index.js";
306
+ import { createHookBus } from "./plugins/opencode/hook-bus.js";
307
+ const calls = [];
308
+ const okImpl = async (tool, args, root) => {
309
+ calls.push({tool, args});
310
+ return {content: JSON.stringify({status: "updated", config: {mode: "audit"}, hash: "h1"})};
311
+ };
312
+ """
313
+
314
+ def test_precommit_deny_blocks_write(self):
315
+ res = run_node(
316
+ self.GATE_SETUP
317
+ + """
318
+ const bus = createHookBus();
319
+ bus.on("pre-commit", () => "deny", {tier: "fast"});
320
+ const h = createSettingsHandlers({root: "/tmp/oc-gate-deny", callMcpImpl: okImpl, hookBus: bus});
321
+ let rpcType = null;
322
+ let data = null;
323
+ try {
324
+ await h.set({updates: {mode: "audit"}}, {});
325
+ } catch (err) { rpcType = err.rpcType; data = err.rpcData; }
326
+ const audit = bus.getAuditLog();
327
+ console.log(JSON.stringify({
328
+ rpcType, written: data && data.written,
329
+ mcpCalls: calls.length,
330
+ gateAudited: audit.some((a) => a.event === "pre-commit" && a.outcome === "deny")
331
+ }));
332
+ """
333
+ )
334
+ self.assertEqual(res.returncode, 0, f"gate-deny test failed: {res.stderr}")
335
+ data = last_json_object(res.stdout)
336
+ self.assertEqual(data["rpcType"], "invalid")
337
+ self.assertFalse(data["written"])
338
+ self.assertEqual(data["mcpCalls"], 0, "denied write must not reach MCP")
339
+ self.assertTrue(data["gateAudited"])
340
+
341
+ def test_precommit_allow_writes_and_stale_guard_preserved(self):
342
+ # Allow proceeds to the MCP write; a stale MCP verdict still surfaces
343
+ # as `stale` (nothing written) — the expected-hash guard is intact.
344
+ res = run_node(
345
+ self.GATE_SETUP
346
+ + """
347
+ const bus = createHookBus();
348
+ bus.on("pre-commit", () => "allow", {tier: "fast"});
349
+ const h = createSettingsHandlers({root: "/tmp/oc-gate-allow", callMcpImpl: okImpl, hookBus: bus});
350
+ const ok = await h.set({updates: {mode: "audit"}}, {});
351
+ const staleImpl = async () => ({content: JSON.stringify({
352
+ status: "stale", expected_hash: "e", current_hash: "c", config: {}
353
+ })});
354
+ const h2 = createSettingsHandlers({root: "/tmp/oc-gate-stale", callMcpImpl: staleImpl, hookBus: bus});
355
+ let staleType = null;
356
+ let staleWritten = null;
357
+ try {
358
+ await h2.set({updates: {mode: "audit"}, expected_hash: "abcdef12"}, {});
359
+ } catch (err) { staleType = err.rpcType; staleWritten = err.rpcData && err.rpcData.written; }
360
+ console.log(JSON.stringify({
361
+ wrote: ok.config.mode, hash: ok.hash, staleType, staleWritten
362
+ }));
363
+ """
364
+ )
365
+ self.assertEqual(res.returncode, 0, f"gate-allow test failed: {res.stderr}")
366
+ data = last_json_object(res.stdout)
367
+ self.assertEqual(data["wrote"], "audit")
368
+ self.assertEqual(data["hash"], "h1")
369
+ self.assertEqual(data["staleType"], "stale")
370
+ self.assertFalse(data["staleWritten"])
371
+
372
+
373
+ class TestHookBusRegistrarAdopt(unittest.TestCase):
374
+ def test_setup_adopts_bus_alongside_existing(self):
375
+ # The bus is adopted with the same adopt/best-effort pattern as the
376
+ # existing four registrations; cleanup disposes (detaches) it.
377
+ res = run_node(
378
+ """
379
+ import plugin, { hookBus } from "./plugins/opencode/index.js";
380
+ const addedCommands = [];
381
+ const addedTools = [];
382
+ const host = {
383
+ options: {},
384
+ command: {
385
+ list: async () => ({data: []}),
386
+ transform: async (fn) => { fn({add: (d) => addedCommands.push(d)}); return {dispose: async () => {}}; }
387
+ },
388
+ tool: {
389
+ transform: async (fn) => { fn({add: (t) => addedTools.push(t)}); return {dispose: async () => {}}; }
390
+ },
391
+ session: { prompt: async () => ({}) }
392
+ };
393
+ const cleanup = await plugin.setup(host);
394
+ const attached = host.iumbtemsHookBus === hookBus;
395
+ await cleanup();
396
+ console.log(JSON.stringify({
397
+ attached,
398
+ detached: host.iumbtemsHookBus === undefined,
399
+ commands: addedCommands.length,
400
+ tools: addedTools.length
401
+ }));
402
+ """
403
+ )
404
+ self.assertEqual(res.returncode, 0, f"adopt test failed: {res.stderr}")
405
+ data = last_json_object(res.stdout)
406
+ self.assertTrue(data["attached"], "bus must be adopted on setup")
407
+ self.assertTrue(data["detached"], "cleanup must dispose the bus")
408
+ self.assertGreater(data["commands"], 0, "existing commands still adopted")
409
+ self.assertGreater(data["tools"], 0, "existing tools still adopted")
410
+
411
+ def test_session_start_deny_never_drops_compaction_push(self):
412
+ # `session-start` is advisory: even a deny still pushes state.
413
+ import tempfile
414
+
415
+ with tempfile.TemporaryDirectory() as tmp:
416
+ research = Path(tmp) / ".research"
417
+ research.mkdir()
418
+ research.joinpath("config.json").write_text(
419
+ json.dumps({"mode": "audit", "search_engine": "duckduckgo"}),
420
+ encoding="utf-8",
421
+ )
422
+ res = run_node(
423
+ f"""
424
+ import plugin, {{ hookBus }} from "./plugins/opencode/index.js";
425
+ hookBus.on("session-start", () => "deny", {{tier: "fast"}});
426
+ let handler = null;
427
+ const host = {{
428
+ options: {{}},
429
+ location: {{directory: {tmp!r}}},
430
+ command: {{list: async () => ({{data: []}}), transform: async () => ({{dispose: async () => {{}}}})}},
431
+ tool: {{transform: async () => ({{dispose: async () => {{}}}})}},
432
+ session: {{
433
+ prompt: async () => ({{}}),
434
+ hook: async (name, fn) => {{ if (name === "compaction") handler = fn; return {{dispose: async () => {{}}}}; }}
435
+ }}
436
+ }};
437
+ await plugin.setup(host);
438
+ const event = {{system: [], directory: {tmp!r}}};
439
+ if (handler) await handler(event);
440
+ const audit = hookBus.getAuditLog().filter((a) => a.event === "session-start");
441
+ console.log(JSON.stringify({{pushed: event.system.length, audited: audit.length}}));
442
+ """
443
+ )
444
+ self.assertEqual(res.returncode, 0, f"advisory test failed: {res.stderr}")
445
+ data = last_json_object(res.stdout)
446
+ self.assertEqual(data["pushed"], 1, "deny must not drop the state push")
447
+ self.assertGreaterEqual(data["audited"], 1, "verdict must be audit-logged")
448
+
449
+
450
+ class TestParityDocPointers(unittest.TestCase):
451
+ def test_every_pointer_resolves(self):
452
+ # Every parity-table / wire-in row resolves to a real file/line; the
453
+ # gap list names tool.execute.before explicitly.
454
+ doc = (PROJECT_ROOT / "docs" / "HOOK_BUS_PARITY.md").read_text(encoding="utf-8")
455
+ self.assertIn("tool.execute.before", doc)
456
+ refs = re.findall(
457
+ r"(plugins/[A-Za-z0-9_./-]+|runner/[A-Za-z0-9_./-]+):(\d+)", doc
458
+ )
459
+ self.assertGreater(len(refs), 0, "parity doc must carry file:line pointers")
460
+ for path, line in sorted(set(refs)):
461
+ target = PROJECT_ROOT / path
462
+ self.assertTrue(target.is_file(), f"parity pointer missing file: {path}")
463
+ total = sum(1 for _ in target.open(encoding="utf-8"))
464
+ self.assertTrue(
465
+ 1 <= int(line) <= total,
466
+ f"parity pointer out of range: {path}:{line} (file has {total} lines)",
467
+ )
468
+
469
+
470
+ class TestHookBusFixRound(unittest.TestCase):
471
+ # QA-B fix-round pins: malformed-verdict fallback tags (FAIL 3a),
472
+ # always-present tool/eventId keys (FAIL 3b), ring-cap drop counter +
473
+ # clear tombstone (FAIL 3c), direct-fs pre-commit gate (FAIL 1),
474
+ # plugin-tool pre/post-tool-use host emits (FAIL 2), and the
475
+ # caller-latency contract (CONDITIONAL note). All fast: small budgets,
476
+ # sync handlers, one MCP spawn at most.
477
+
478
+ def test_malformed_verdict_tagged_fallback(self):
479
+ res = run_node(
480
+ BUS_IMPORT
481
+ + """
482
+ const bus = createHookBus();
483
+ bus.on("pre-tool-use", () => 42, {tier: "fast", name: "num"});
484
+ bus.on("pre-tool-use", () => ({verdict: "bogus"}), {tier: "fast", name: "obj"});
485
+ bus.on("pre-tool-use", () => ["deny"], {tier: "slow", name: "arr"});
486
+ bus.on("pre-tool-use", () => "needs review", {tier: "slow", name: "reason"});
487
+ bus.on("pre-tool-use", () => ({}), {tier: "fast", name: "emptyobj"});
488
+ const r = await bus.emit("pre-tool-use", {tool: "webfetch", eventId: "e1"});
489
+ console.log(JSON.stringify({allowed: r.allowed, audit: bus.getAuditLog()}));
490
+ """
491
+ )
492
+ self.assertEqual(res.returncode, 0, f"fallback-tag test failed: {res.stderr}")
493
+ data = last_json_object(res.stdout)
494
+ self.assertTrue(data["allowed"], "malformed verdicts must fail open")
495
+ self.assertEqual(len(data["audit"]), 5)
496
+ for entry in data["audit"]:
497
+ self.assertTrue(
498
+ entry.get("fallback"), f"malformed verdict not tagged: {entry}"
499
+ )
500
+ self.assertTrue(
501
+ entry.get("note"), f"malformed verdict has no note: {entry}"
502
+ )
503
+ self.assertEqual(entry["verdict"], "allow")
504
+ by_name = {a["handler"]: a for a in data["audit"]}
505
+ self.assertEqual(by_name["reason"].get("reason"), "needs review")
506
+
507
+ def test_audit_always_carries_tool_and_eventId(self):
508
+ res = run_node(
509
+ BUS_IMPORT
510
+ + """
511
+ const bus = createHookBus();
512
+ bus.on("stop", () => "allow", {tier: "fast", name: "w"});
513
+ await bus.emit("stop", {});
514
+ await bus.emit("not-an-event", {});
515
+ console.log(JSON.stringify({audit: bus.getAuditLog()}));
516
+ """
517
+ )
518
+ self.assertEqual(res.returncode, 0, f"audit-keys test failed: {res.stderr}")
519
+ data = last_json_object(res.stdout)
520
+ self.assertGreater(len(data["audit"]), 0)
521
+ for entry in data["audit"]:
522
+ self.assertIn("tool", entry, f"audit entry missing tool key: {entry}")
523
+ self.assertIn("eventId", entry, f"audit entry missing eventId key: {entry}")
524
+ self.assertIsNone(entry["tool"])
525
+ self.assertIsNone(entry["eventId"])
526
+
527
+ def test_ring_cap_drop_counter(self):
528
+ res = run_node(
529
+ BUS_IMPORT
530
+ + """
531
+ const bus = createHookBus();
532
+ bus.on("stop", () => "allow", {tier: "fast"});
533
+ for (let i = 0; i < 1002; i++) {
534
+ await bus.emit("stop", {eventId: "e" + i});
535
+ }
536
+ const stats = bus.getAuditStats();
537
+ console.log(JSON.stringify({
538
+ entries: bus.getAuditLog().length,
539
+ dropped: stats.dropped,
540
+ capacity: stats.capacity
541
+ }));
542
+ """
543
+ )
544
+ self.assertEqual(res.returncode, 0, f"drop-counter test failed: {res.stderr}")
545
+ data = last_json_object(res.stdout)
546
+ self.assertEqual(data["entries"], 1000)
547
+ self.assertEqual(data["dropped"], 2)
548
+ self.assertEqual(data["capacity"], 1000)
549
+
550
+ def test_clear_audit_log_writes_tombstone(self):
551
+ res = run_node(
552
+ BUS_IMPORT
553
+ + """
554
+ const bus = createHookBus();
555
+ bus.on("stop", () => "allow", {tier: "fast"});
556
+ await bus.emit("stop", {});
557
+ bus.clearAuditLog();
558
+ const log = bus.getAuditLog();
559
+ console.log(JSON.stringify({len: log.length, entry: log[0]}));
560
+ """
561
+ )
562
+ self.assertEqual(res.returncode, 0, f"tombstone test failed: {res.stderr}")
563
+ data = last_json_object(res.stdout)
564
+ self.assertEqual(data["len"], 1)
565
+ entry = data["entry"]
566
+ self.assertEqual(entry["event"], "audit-clear")
567
+ self.assertTrue(entry.get("fallback"))
568
+ self.assertIn("1 entries discarded", entry.get("note", ""))
569
+ self.assertIn("tool", entry)
570
+ self.assertIn("eventId", entry)
571
+
572
+ def test_emit_latency_bounded_by_slow_budget(self):
573
+ # CONDITIONAL note: a hung slow handler delays the caller by at most
574
+ # the slow budget — never indefinitely.
575
+ res = run_node(
576
+ BUS_IMPORT
577
+ + """
578
+ const bus = createHookBus();
579
+ bus.on("stop", () => new Promise(() => {}), {tier: "slow", name: "hung"});
580
+ const t0 = Date.now();
581
+ const r = await bus.emit("stop", {}, {slowTimeoutMs: 120});
582
+ const elapsed = Date.now() - t0;
583
+ const audit = bus.getAuditLog();
584
+ console.log(JSON.stringify({
585
+ allowed: r.allowed, elapsed,
586
+ outcomes: audit.map((a) => a.outcome)
587
+ }));
588
+ """
589
+ )
590
+ self.assertEqual(res.returncode, 0, f"latency-bound test failed: {res.stderr}")
591
+ data = last_json_object(res.stdout)
592
+ self.assertTrue(data["allowed"], "hung slow handler must fail open")
593
+ self.assertLess(data["elapsed"], 2000, "emit must resolve near the slow budget")
594
+ self.assertIn("timeout", data["outcomes"])
595
+
596
+ def test_direct_fs_gate_deny_blocks_and_audits(self):
597
+ # FAIL 1: the direct-fs fallback leg gates on pre-commit too.
598
+ res = run_node(
599
+ """
600
+ import { gateDirectFsPreCommit } from "./plugins/opencode/tui.js";
601
+ import { createHookBus } from "./plugins/opencode/hook-bus.js";
602
+ const bus = createHookBus();
603
+ bus.on("pre-commit", () => "deny", {tier: "fast"});
604
+ const denied = await gateDirectFsPreCommit(bus, {
605
+ draft: {mode: "audit"}, baseDir: "/tmp/oc-fs-gate", guard: {expectedHash: "abcdef12"}
606
+ });
607
+ const open = await gateDirectFsPreCommit(null, {draft: {}, baseDir: "/tmp/oc-fs-gate"});
608
+ const audit = bus.getAuditLog();
609
+ console.log(JSON.stringify({
610
+ denied: denied.allowed, open: open.allowed,
611
+ audited: audit.some((a) => a.event === "pre-commit" && a.outcome === "deny")
612
+ }));
613
+ """
614
+ )
615
+ self.assertEqual(res.returncode, 0, f"direct-fs gate test failed: {res.stderr}")
616
+ data = last_json_object(res.stdout)
617
+ self.assertFalse(data["denied"], "direct-fs deny must block")
618
+ self.assertTrue(data["open"], "null bus must fail open")
619
+ self.assertTrue(
620
+ data["audited"], "denied fallback write must leave an audit entry"
621
+ )
622
+
623
+ def test_direct_fs_stale_guard_preserved(self):
624
+ # FAIL 1: the gate does NOT replace the stale-write guard — a stale
625
+ # expected hash still refuses the write via saveConfig (CONFIG_STALE).
626
+ res = run_node(
627
+ """
628
+ import fs from "node:fs";
629
+ import os from "node:os";
630
+ import path from "node:path";
631
+ import { saveConfig, loadConfig, configHash } from "./plugins/opencode/config-io.js";
632
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-fs-stale-"));
633
+ const seed = loadConfig(dir);
634
+ saveConfig({...seed}, dir, {});
635
+ let staleCode = null;
636
+ try {
637
+ saveConfig({...seed, mode: "scout"}, dir, {expected_hash: "00000000"});
638
+ } catch (err) { staleCode = err.code; }
639
+ const h = configHash(dir);
640
+ const second = saveConfig({...seed}, dir, {expected_hash: h});
641
+ console.log(JSON.stringify({staleCode, wrote: typeof second.hash === "string"}));
642
+ """
643
+ )
644
+ self.assertEqual(res.returncode, 0, f"stale-guard test failed: {res.stderr}")
645
+ data = last_json_object(res.stdout)
646
+ self.assertEqual(data["staleCode"], "CONFIG_STALE")
647
+ self.assertTrue(data["wrote"])
648
+
649
+ def test_shipped_path_threads_bus_rpc_null_unthreaded(self):
650
+ # qa-b bypass repro: the SHIPPED TUI path is RPC-null with no manual
651
+ # threading — a denying pre-commit handler must still deny the write
652
+ # AND leave a pre-commit audit entry (previously: write landed, 0
653
+ # audits — fail-open on missing wiring). Stale-guard coverage lives in
654
+ # test_direct_fs_stale_guard_preserved.
655
+ import tempfile
656
+
657
+ with tempfile.TemporaryDirectory() as tmp:
658
+ shipped = Path(tmp) / "shipped.js"
659
+ shipped_body = """
660
+ import fs from "node:fs";
661
+ import os from "node:os";
662
+ import path from "node:path";
663
+ import { hookBus } from "__INDEX__";
664
+ import { openSettingsWizard, setupTui, registerWizardSlashCommand } from "__TUI__";
665
+ function mockApi(dir, selectQueue) {
666
+ const toasts = [];
667
+ const q = [...selectQueue];
668
+ return {
669
+ api: {
670
+ directory: dir,
671
+ ui: {
672
+ toast: { show: (t) => toasts.push(t) },
673
+ dialog: {
674
+ select: async () => (q.length ? q.shift() : null),
675
+ prompt: async () => null,
676
+ confirm: async () => true,
677
+ },
678
+ },
679
+ command: {
680
+ list: async () => ({data: []}),
681
+ transform: async (fn) => {
682
+ const adds = [];
683
+ fn({add: (d) => adds.push(d)});
684
+ return {dispose: async () => {}, adds};
685
+ },
686
+ reload: async () => {},
687
+ },
688
+ },
689
+ toasts,
690
+ };
691
+ }
692
+ // Leg 1: direct shipped call — openSettingsWizard(api, dir)
693
+ // with NO opts and NO preset host bus (RPC-null).
694
+ const dir1 = fs.mkdtempSync(path.join(os.tmpdir(), "oc-shipped1-"));
695
+ const m1 = mockApi(dir1, ["__iumbtems_save__"]);
696
+ setupTui(m1.api);
697
+ const attached = m1.api.iumbtemsHookBus === hookBus;
698
+ const mark1 = hookBus.getAuditLog().length;
699
+ const off = hookBus.on("pre-commit", () => "deny", {tier: "fast", name: "shipped-deny"});
700
+ let r1 = null;
701
+ try { r1 = await openSettingsWizard(m1.api, dir1); }
702
+ finally { /* keep handler for leg 2 */ }
703
+ // Leg 2: slash-command shipped path — execute() captured from
704
+ // registerWizardSlashCommand (previously called the wizard
705
+ // with NO opts).
706
+ const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "oc-shipped2-"));
707
+ const m2 = mockApi(dir2, ["__iumbtems_save__"]);
708
+ setupTui(m2.api);
709
+ const reg = await registerWizardSlashCommand(m2.api);
710
+ const adds = (reg && reg.adds) || [];
711
+ const slash = adds.find((d) => d.name === "swarm-config");
712
+ const mark2 = hookBus.getAuditLog().length;
713
+ if (slash) await slash.execute();
714
+ off();
715
+ const audit = hookBus.getAuditLog();
716
+ const denied1 = audit.slice(mark1).some((a) => a.event === "pre-commit" && a.outcome === "deny");
717
+ const denied2 = audit.slice(mark2).some((a) => a.event === "pre-commit" && a.outcome === "deny");
718
+ let wrote1 = false;
719
+ try { fs.statSync(path.join(dir1, ".research", "config.json")); wrote1 = true; } catch { wrote1 = false; }
720
+ let wrote2 = false;
721
+ try { fs.statSync(path.join(dir2, ".research", "config.json")); wrote2 = true; } catch { wrote2 = false; }
722
+ console.log(JSON.stringify({
723
+ attached, hasSlash: !!slash,
724
+ ok: r1 && r1.ok, reason: r1 && r1.reason, written: r1 && r1.written,
725
+ denied1, denied2, wrote1, wrote2
726
+ }));
727
+ """
728
+ shipped_body = shipped_body.replace(
729
+ "__INDEX__", str(PROJECT_ROOT / "plugins" / "opencode" / "index.js")
730
+ ).replace("__TUI__", str(PROJECT_ROOT / "plugins" / "opencode" / "tui.js"))
731
+ shipped.write_text(shipped_body, encoding="utf-8")
732
+ res = subprocess.run(
733
+ ["node", str(shipped)],
734
+ capture_output=True,
735
+ text=True,
736
+ cwd=str(PROJECT_ROOT),
737
+ )
738
+ self.assertEqual(
739
+ res.returncode, 0, f"shipped-path test failed: {res.stderr}"
740
+ )
741
+ data = last_json_object(res.stdout)
742
+ self.assertTrue(data["attached"], "setupTui must adopt the shared bus")
743
+ self.assertTrue(data["hasSlash"], "slash command must be registered")
744
+ self.assertFalse(data["ok"], "shipped denying write must not report ok")
745
+ self.assertEqual(data["reason"], "pre-commit-denied")
746
+ self.assertFalse(data["written"])
747
+ self.assertTrue(data["denied1"], "shipped direct call must audit the deny")
748
+ self.assertTrue(
749
+ data["denied2"], "shipped slash execute must audit the deny"
750
+ )
751
+ self.assertFalse(
752
+ data["wrote1"], "denied shipped write must not land (leg 1)"
753
+ )
754
+ self.assertFalse(
755
+ data["wrote2"], "denied shipped write must not land (leg 2)"
756
+ )
757
+
758
+ def test_plugin_tool_pre_and_post_gates(self):
759
+ # FAIL 2: host emit sites for pre/post-tool-use in the plugin-tool
760
+ # execute leg — deny blocks execution / suppresses delivery, shape
761
+ # stays `{content}`-only either way.
762
+ res = run_node(
763
+ """
764
+ import plugin, { hookBus } from "./plugins/opencode/index.js";
765
+ let toolExec = null;
766
+ const host = {
767
+ options: {},
768
+ command: {
769
+ list: async () => ({data: []}),
770
+ transform: async () => ({dispose: async () => {}})
771
+ },
772
+ tool: {
773
+ transform: async (fn) => {
774
+ const adds = [];
775
+ fn({add: (t) => adds.push(t)});
776
+ toolExec = adds.find((t) => t.name === "iumbtems_config").execute;
777
+ return {dispose: async () => {}};
778
+ }
779
+ },
780
+ session: { prompt: async () => ({}) }
781
+ };
782
+ const cleanup = await plugin.setup(host);
783
+ const offPre = hookBus.on("pre-tool-use", () => "deny", {tier: "fast"});
784
+ let blocked = null;
785
+ try { blocked = await toolExec({}, {}); } finally { offPre(); }
786
+ const offPost = hookBus.on("post-tool-use", () => "deny", {tier: "fast"});
787
+ let suppressed = null;
788
+ try { suppressed = await toolExec({}, {}); } finally { offPost(); }
789
+ await cleanup();
790
+ console.log(JSON.stringify({blocked, suppressed}));
791
+ """
792
+ )
793
+ self.assertEqual(res.returncode, 0, f"tool-gate test failed: {res.stderr}")
794
+ data = last_json_object(res.stdout)
795
+ self.assertEqual(sorted(data["blocked"].keys()), ["content"])
796
+ self.assertIn("denied by pre-tool-use", data["blocked"]["content"])
797
+ self.assertEqual(sorted(data["suppressed"].keys()), ["content"])
798
+ self.assertIn("suppressed by post-tool-use", data["suppressed"]["content"])
799
+
800
+
801
+ if __name__ == "__main__":
802
+ unittest.main()