@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,539 @@
1
+ #!/usr/bin/env python3
2
+ """Phase 08 waiver re-opens (W9, W17, W18, W6) — R1-R4 pinning tests.
3
+
4
+ Dossier evidence hashes (`.roadmap/08-waiver-reopens/dossier.json`):
5
+ - index.js sha256:3a4a4cb6ce2bc34b72246291b321009a5a09c67c393467fd07d5cc0e68d9dac3
6
+ - configure.py sha256:10c893d88e9953174944fb04a3d49a7c9b63655a6da671d8eab0ff24b42a3425
7
+ - preflight.py sha256:762d31ced84b10e86203d88f104704c9763f23838612fba02652018a0c80797e
8
+ - mcp_server.py sha256:34276d2db3035f75dda74cf0b64cabf4325c423686de14325aec808e6da8e20f
9
+
10
+ R1 (W9): sanitizer single-segment/file:// boundary refined to
11
+ system-internal disclosure, per-item, without over-redacting user echoes.
12
+ R2 (W17): FIFO-named-config hang hardened — bounded-reader port to PY
13
+ `configure.py` and JS `config-io.js`; the hang-regression proof runs the
14
+ FIFO-touching reads in a subprocess with a timeout (pre-fix they block
15
+ forever), while in-process tests pin normal-file equivalence.
16
+ R3 (W18): `heal_config` zero-caller docstrings corrected to the true
17
+ non-healing-read architecture (+ SKILL.md).
18
+ R4 (W6): `gate` reachable through the `iumbtems_factory` tool surface
19
+ (PY handler forwards --run/--phase/--action/--reason; JS catalog enum).
20
+ """
21
+
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ import unittest
29
+ from pathlib import Path
30
+
31
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
32
+ if str(PROJECT_ROOT) not in sys.path:
33
+ sys.path.insert(0, str(PROJECT_ROOT))
34
+
35
+
36
+ def run_node(code, timeout=60):
37
+ return subprocess.run(
38
+ ["node", "--input-type=module", "-e", code],
39
+ capture_output=True,
40
+ text=True,
41
+ cwd=str(PROJECT_ROOT),
42
+ timeout=timeout,
43
+ )
44
+
45
+
46
+ def last_json_object(stdout):
47
+ lines = [l.strip() for l in stdout.strip().split("\n") if l.strip().startswith("{")]
48
+ return json.loads(lines[-1])
49
+
50
+
51
+ SETTINGS_PROLOGUE = """
52
+ import { createSettingsHandlers } from "./plugins/opencode/index.js";
53
+ function rpcCtx() {
54
+ return {
55
+ signal: null,
56
+ error: (type, message, data) => {
57
+ const e = new Error(message);
58
+ e.type = type;
59
+ e.data = data;
60
+ throw e;
61
+ },
62
+ };
63
+ }
64
+ const FIXED = "Config write rejected: canonical config surface returned an unreadable failure.";
65
+ async function probe(items) {
66
+ const handlers = createSettingsHandlers({
67
+ root: "/tmp/oc-p08",
68
+ callMcpImpl: async () => ({
69
+ content: JSON.stringify({ status: "error", code: "CONFIG_VALIDATION_FAILED", written: false, errors: items }),
70
+ status: "success",
71
+ }),
72
+ storage: null,
73
+ });
74
+ try {
75
+ await handlers.set({ updates: { mode: "scout" } }, rpcCtx());
76
+ return { threw: false };
77
+ } catch (e) {
78
+ return { threw: true, type: e.type || e.rpcType || null, errors: (e.data || e.rpcData || {}).errors || null };
79
+ }
80
+ }
81
+ """
82
+
83
+
84
+ class TestR1SanitizerBoundary(unittest.TestCase):
85
+ """R1 (W9): single-segment system roots + file:// URIs are system-internal
86
+ disclosures (redacted per-item); ordinary user echoes still pass."""
87
+
88
+ CLEAN_ENUM = "mode: value 'nope' not in enum ['research', 'audit']"
89
+ # A user-echoed single-segment value that is NOT a system root.
90
+ CLEAN_SINGLE_SEGMENT_ECHO = "mode: value '/v1' not in enum ['research', 'audit']"
91
+
92
+ def _probe(self, items):
93
+ res = run_node(
94
+ SETTINGS_PROLOGUE
95
+ + f"""
96
+ const out = await probe({json.dumps(items)});
97
+ console.log(JSON.stringify(out));
98
+ """
99
+ )
100
+ self.assertEqual(res.returncode, 0, res.stderr)
101
+ return last_json_object(res.stdout)
102
+
103
+ def test_single_segment_system_roots_redacted(self):
104
+ for item in (
105
+ "python3: can't open file '/etc/iumbtems.json': [Errno 2] No such file or directory",
106
+ "config path /tmp/pipe is not a regular file",
107
+ "failed to read /var state",
108
+ ):
109
+ with self.subTest(item=item):
110
+ d = self._probe([item])
111
+ self.assertTrue(d["threw"])
112
+ self.assertEqual(
113
+ d["errors"],
114
+ [
115
+ "Config write rejected: canonical config surface returned an "
116
+ "unreadable failure."
117
+ ],
118
+ )
119
+
120
+ def test_file_uris_redacted(self):
121
+ for item in (
122
+ "python3: can't open 'file:///etc/iumbtems/config.json': [Errno 2]",
123
+ "fetch failed for file://server/share/config.json",
124
+ ):
125
+ with self.subTest(item=item):
126
+ d = self._probe([item])
127
+ self.assertTrue(d["threw"])
128
+ self.assertEqual(len(d["errors"]), 1)
129
+ self.assertTrue(d["errors"][0].startswith("Config write rejected:"))
130
+
131
+ def test_user_echoes_still_pass(self):
132
+ for item in (self.CLEAN_ENUM, self.CLEAN_SINGLE_SEGMENT_ECHO):
133
+ with self.subTest(item=item):
134
+ d = self._probe([item])
135
+ self.assertTrue(d["threw"])
136
+ self.assertEqual(d["errors"], [item])
137
+
138
+ def test_per_item_design_holds(self):
139
+ d = self._probe([self.CLEAN_ENUM, "/etc/iumbtems.json is missing"])
140
+ self.assertTrue(d["threw"])
141
+ self.assertEqual(len(d["errors"]), 2)
142
+ self.assertEqual(d["errors"][0], self.CLEAN_ENUM)
143
+ self.assertTrue(d["errors"][1].startswith("Config write rejected:"))
144
+
145
+ def test_bare_root_trailing_punct_redacted(self):
146
+ # REWORK-2 L1: bare-root + sentence punctuation redacts; multi-segment
147
+ # `/etc/foo.` already redacted via the >=2-segment arm (untouched).
148
+ for item in (
149
+ "/lib.",
150
+ "/etc.",
151
+ "/lib!",
152
+ "/tmp?",
153
+ "/lib]",
154
+ "/lib}",
155
+ "error at /lib.",
156
+ "failed on /etc!",
157
+ "saw /tmp?",
158
+ "x /lib] y",
159
+ "x /lib} y",
160
+ ):
161
+ with self.subTest(item=item):
162
+ d = self._probe([item])
163
+ self.assertTrue(d["threw"])
164
+ self.assertEqual(len(d["errors"]), 1)
165
+ self.assertTrue(d["errors"][0].startswith("Config write rejected:"))
166
+
167
+ def test_bracket_brace_dash_prefix_redacted(self):
168
+ # REWORK-2 L2: `[`, `{`, `-` prefix members redact.
169
+ for item in (
170
+ "[/etc]",
171
+ "[/lib]",
172
+ "[/lib64]",
173
+ "{/etc}",
174
+ "-/etc",
175
+ "x-/lib64,y",
176
+ "error [/lib] here",
177
+ "err {/etc} here",
178
+ ):
179
+ with self.subTest(item=item):
180
+ d = self._probe([item])
181
+ self.assertTrue(d["threw"])
182
+ self.assertEqual(len(d["errors"]), 1)
183
+ self.assertTrue(d["errors"][0].startswith("Config write rejected:"))
184
+
185
+ def test_canonical_corpus_passes_verbatim(self):
186
+ # Over-redaction gate (blocking): the full canonical-value corpus must
187
+ # ALL pass verbatim — this is the `-`-prefix safety proof (canonical
188
+ # values contain no `/`-root tokens, so `-`-prefixed path tokens are
189
+ # disclosures). If any of these redacted, the `-` member must be
190
+ # dropped and re-waived with trigger instead of forcing it.
191
+ corpus = (
192
+ "duckduckgo",
193
+ "brave",
194
+ "firecrawl",
195
+ "searxng",
196
+ "research",
197
+ "audit",
198
+ "scout",
199
+ "hybrid",
200
+ "brainstorm",
201
+ "darkharvest",
202
+ "auto",
203
+ "claude",
204
+ "opencode",
205
+ "MIT",
206
+ "Apache-2.0",
207
+ "BSD-3-Clause",
208
+ "ISC",
209
+ "dag",
210
+ "auction",
211
+ "None",
212
+ "2",
213
+ "0.75",
214
+ "true",
215
+ "false",
216
+ "0.88",
217
+ "/v1",
218
+ "reboot",
219
+ "snapshot",
220
+ "library book",
221
+ self.CLEAN_ENUM,
222
+ self.CLEAN_SINGLE_SEGMENT_ECHO,
223
+ )
224
+ for item in corpus:
225
+ with self.subTest(item=item):
226
+ d = self._probe([item])
227
+ self.assertTrue(d["threw"])
228
+ self.assertEqual(d["errors"], [item])
229
+
230
+
231
+ class TestR2FifoHardeningPython(unittest.TestCase):
232
+ """R2 (W17) PY: every config.json pre-read refuses FIFOs instead of hanging."""
233
+
234
+ def test_fifo_reads_do_not_hang(self):
235
+ # Hang-regression proof: pre-fix, each of these blocked forever on the
236
+ # open(). Run FIFO-touching reads in a child with a timeout.
237
+ with tempfile.TemporaryDirectory() as tmp:
238
+ os.mkfifo(os.path.join(tmp, "config.json"))
239
+ code = (
240
+ "import json, sys; "
241
+ "sys.path.insert(0, '.'); "
242
+ "from skills.swarm_config.configure import "
243
+ "load_config, heal_config, config_hash, snapshot; "
244
+ f"d = {json.dumps(tmp)}; "
245
+ "cfg = load_config(d); "
246
+ "assert isinstance(cfg, dict) and cfg.get('mode') == 'research', cfg.get('mode'); "
247
+ "assert heal_config(d) is False; "
248
+ "assert config_hash(d) is None; "
249
+ "snap = snapshot(d); "
250
+ "assert snap['hash'] is None and snap['config'].get('mode') == 'research'; "
251
+ "print(json.dumps({'ok': True})); "
252
+ )
253
+ res = subprocess.run(
254
+ [sys.executable, "-c", code],
255
+ capture_output=True,
256
+ text=True,
257
+ cwd=str(PROJECT_ROOT),
258
+ timeout=60,
259
+ )
260
+ self.assertEqual(res.returncode, 0, res.stderr)
261
+ self.assertTrue(json.loads(res.stdout.strip())["ok"])
262
+
263
+ def test_fifo_save_fails_closed(self):
264
+ with tempfile.TemporaryDirectory() as tmp:
265
+ os.mkfifo(os.path.join(tmp, "config.json"))
266
+ code = (
267
+ "import sys\n"
268
+ "sys.path.insert(0, '.')\n"
269
+ "from skills.swarm_config.configure import save_config, ConfigError\n"
270
+ f"d = {json.dumps(tmp)}\n"
271
+ "try:\n"
272
+ " save_config({'mode': 'audit'}, d)\n"
273
+ " print('SAVED')\n"
274
+ "except ConfigError as exc:\n"
275
+ " print('REFUSED:' + exc.code)\n"
276
+ )
277
+ res = subprocess.run(
278
+ [sys.executable, "-c", code],
279
+ capture_output=True,
280
+ text=True,
281
+ cwd=str(PROJECT_ROOT),
282
+ timeout=60,
283
+ )
284
+ self.assertEqual(res.returncode, 0, res.stderr)
285
+ self.assertTrue(
286
+ res.stdout.strip().startswith("REFUSED:CONFIG_UNREADABLE"),
287
+ res.stdout,
288
+ )
289
+
290
+ def test_normal_files_unchanged(self):
291
+ # Blast-radius pin: regular files behave byte-identically (hash over
292
+ # exact bytes, merge, round-trip).
293
+ from skills.swarm_config.configure import (
294
+ config_hash,
295
+ load_config,
296
+ save_config,
297
+ )
298
+
299
+ with tempfile.TemporaryDirectory() as tmp:
300
+ raw = b'{"mode": "audit", "max_iterations": 3}'
301
+ (Path(tmp) / "config.json").write_bytes(raw)
302
+ self.assertEqual(config_hash(tmp), hashlib.sha256(raw).hexdigest())
303
+ cfg = load_config(tmp)
304
+ self.assertEqual(cfg["mode"], "audit")
305
+ self.assertEqual(cfg["max_iterations"], 3)
306
+ # Defaults still merge under a partial file.
307
+ self.assertEqual(cfg["search_engine"], "duckduckgo")
308
+ save_config({"search_engine": "searxng"}, tmp)
309
+ self.assertEqual(load_config(tmp)["search_engine"], "searxng")
310
+ self.assertEqual(load_config(tmp)["mode"], "audit")
311
+
312
+
313
+ class TestR2FifoHardeningJs(unittest.TestCase):
314
+ """R2 (W17) JS: config-io readers refuse FIFOs; save fails closed."""
315
+
316
+ def test_fifo_reads_do_not_hang(self):
317
+ with tempfile.TemporaryDirectory() as tmp:
318
+ os.mkfifo(os.path.join(tmp, "config.json"))
319
+ res = run_node(
320
+ f"""
321
+ import {{ readConfigSnapshot, loadConfig, readRawConfig, saveConfig }} from "./plugins/opencode/config-io.js";
322
+ const dir = {json.dumps(tmp)};
323
+ const snap = readConfigSnapshot(dir);
324
+ const cfg = loadConfig(dir);
325
+ const raw = readRawConfig(dir);
326
+ let saved = "no-throw";
327
+ try {{
328
+ saveConfig({{ mode: "audit" }}, dir);
329
+ }} catch (e) {{
330
+ saved = e && e.code ? e.code : "threw";
331
+ }}
332
+ console.log(JSON.stringify({{
333
+ hash: snap.hash, malformed: snap.malformed, raw,
334
+ mode: cfg && cfg.mode, engine: cfg && cfg.search_engine,
335
+ saved,
336
+ }}));
337
+ """,
338
+ timeout=60,
339
+ )
340
+ self.assertEqual(res.returncode, 0, res.stderr)
341
+ d = last_json_object(res.stdout)
342
+ # FIFO refused: no hash, no raw, defaults, save refused — and the
343
+ # process returned instead of hanging.
344
+ self.assertIsNone(d["hash"])
345
+ self.assertIsNone(d["raw"])
346
+ self.assertEqual(d["mode"], "research")
347
+ self.assertEqual(d["engine"], "duckduckgo")
348
+ self.assertEqual(d["saved"], "CONFIG_UNREADABLE")
349
+
350
+ def test_normal_files_unchanged(self):
351
+ with tempfile.TemporaryDirectory() as tmp:
352
+ (Path(tmp) / "config.json").write_text(
353
+ '{"mode": "audit", "max_iterations": 3}', encoding="utf-8"
354
+ )
355
+ res = run_node(
356
+ f"""
357
+ import {{ readConfigSnapshot, loadConfig }} from "./plugins/opencode/config-io.js";
358
+ const dir = {json.dumps(tmp)};
359
+ const snap = readConfigSnapshot(dir);
360
+ const cfg = loadConfig(dir);
361
+ console.log(JSON.stringify({{ hash: snap.hash, mode: cfg.mode, depth: cfg.max_iterations, engine: cfg.search_engine }}));
362
+ """,
363
+ timeout=60,
364
+ )
365
+ self.assertEqual(res.returncode, 0, res.stderr)
366
+ d = last_json_object(res.stdout)
367
+ self.assertEqual(
368
+ d["hash"],
369
+ hashlib.sha256(b'{"mode": "audit", "max_iterations": 3}').hexdigest(),
370
+ )
371
+ self.assertEqual(d["mode"], "audit")
372
+ self.assertEqual(d["depth"], 3)
373
+ self.assertEqual(d["engine"], "duckduckgo")
374
+
375
+
376
+ class TestR3HealConfigDocs(unittest.TestCase):
377
+ """R3 (W18): docstrings describe the true non-healing-read architecture."""
378
+
379
+ def test_configure_docstrings_state_non_healing_truth(self):
380
+ text = (PROJECT_ROOT / "skills" / "swarm_config" / "configure.py").read_text(
381
+ encoding="utf-8"
382
+ )
383
+ # The false live-healing claim is gone...
384
+ self.assertNotIn("the CLI and MCP surfaces call this", text)
385
+ self.assertNotIn("persists that migration on next touch", text)
386
+ # ...replaced by the non-healing truth naming every non-healing read.
387
+ self.assertIn("NO\n production read path calls this", text)
388
+ self.assertIn("non-healing", text)
389
+ for marker in ("`--show`", "`load_config`", "opportunistically"):
390
+ self.assertIn(marker, text)
391
+
392
+ def test_skill_doc_states_non_healing_truth(self):
393
+ text = (PROJECT_ROOT / "skills" / "swarm_config" / "SKILL.md").read_text(
394
+ encoding="utf-8"
395
+ )
396
+ self.assertIn("Non-healing reads", text)
397
+ self.assertIn("no production", text)
398
+
399
+ def test_heal_config_still_exported_and_safe(self):
400
+ from skills.swarm_config import heal_config
401
+
402
+ with tempfile.TemporaryDirectory() as tmp:
403
+ # Absent file: nothing to heal.
404
+ self.assertFalse(heal_config(tmp))
405
+
406
+
407
+ class TestR4GateSurface(unittest.TestCase):
408
+ """R4 (W6): gate reachable through the iumbtems_factory tool surface."""
409
+
410
+ def test_handler_forwards_gate(self):
411
+ from runner.mcp_server import _handle_factory
412
+
413
+ with tempfile.TemporaryDirectory() as tmp:
414
+ init = json.loads(
415
+ _handle_factory(
416
+ {"command": "init", "run": "p08-gate", "project_dir": tmp}
417
+ )
418
+ )
419
+ self.assertEqual(init["status"], "ok")
420
+ opened = json.loads(
421
+ _handle_factory(
422
+ {
423
+ "command": "gate",
424
+ "action": "open",
425
+ "run": "p08-gate",
426
+ "phase": "08-waiver-reopens",
427
+ "project_dir": tmp,
428
+ }
429
+ )
430
+ )
431
+ self.assertEqual(opened["status"], "ok", opened)
432
+ self.assertEqual(opened["command"], "gate")
433
+ state = json.loads(
434
+ (Path(tmp) / ".factory" / "p08-gate" / "state.json").read_text(
435
+ encoding="utf-8"
436
+ )
437
+ )
438
+ self.assertEqual(state["gates"]["08-waiver-reopens"]["status"], "open")
439
+ counted = json.loads(
440
+ _handle_factory(
441
+ {
442
+ "command": "gate",
443
+ "action": "count",
444
+ "run": "p08-gate",
445
+ "phase": "08-waiver-reopens",
446
+ "project_dir": tmp,
447
+ }
448
+ )
449
+ )
450
+ self.assertEqual(counted["status"], "ok", counted)
451
+ settled = json.loads(
452
+ _handle_factory(
453
+ {
454
+ "command": "gate",
455
+ "action": "settle",
456
+ "run": "p08-gate",
457
+ "phase": "08-waiver-reopens",
458
+ "reason": "phase-08 receipt",
459
+ "project_dir": tmp,
460
+ }
461
+ )
462
+ )
463
+ self.assertEqual(settled["status"], "ok", settled)
464
+ state = json.loads(
465
+ (Path(tmp) / ".factory" / "p08-gate" / "state.json").read_text(
466
+ encoding="utf-8"
467
+ )
468
+ )
469
+ self.assertEqual(state["gates"]["08-waiver-reopens"]["status"], "settled")
470
+
471
+ def test_handler_rejects_bad_action_and_bad_command(self):
472
+ from runner.mcp_server import _handle_factory
473
+
474
+ with tempfile.TemporaryDirectory() as tmp:
475
+ _handle_factory(
476
+ {"command": "init", "run": "p08-gate-bad", "project_dir": tmp}
477
+ )
478
+ bad = json.loads(
479
+ _handle_factory(
480
+ {
481
+ "command": "gate",
482
+ "action": "frobnicate",
483
+ "run": "p08-gate-bad",
484
+ "phase": "x",
485
+ "project_dir": tmp,
486
+ }
487
+ )
488
+ )
489
+ # Invalid action: the helper exits 2 (argparse), which the handler
490
+ # surfaces as escalated/error — never ok.
491
+ self.assertIn(bad["status"], ("error", "escalated"), bad)
492
+ self.assertEqual(bad["returncode"], 2, bad)
493
+ with self.assertRaises(ValueError):
494
+ _handle_factory({"command": "launch", "project_dir": tmp})
495
+
496
+ def test_tool_spec_and_js_catalog_advertise_gate(self):
497
+ from runner.mcp_server import build_tools
498
+
499
+ spec = next(t for t in build_tools() if t.name == "iumbtems_factory")
500
+ schema = spec.input_schema
501
+ self.assertIn("gate", schema["properties"]["command"]["enum"])
502
+ self.assertIn(
503
+ "open",
504
+ schema["properties"]["action"]["enum"],
505
+ )
506
+ for action in ("settle", "approve", "waive", "escalate", "count"):
507
+ self.assertIn(action, schema["properties"]["action"]["enum"])
508
+ res = run_node(
509
+ """
510
+ import { TOOL_CATALOG } from "./plugins/opencode/index.js";
511
+ const entry = TOOL_CATALOG.find((t) => t.name === "iumbtems_factory");
512
+ console.log(JSON.stringify({ input: entry.input, description: entry.description }));
513
+ """
514
+ )
515
+ self.assertEqual(res.returncode, 0, res.stderr)
516
+ advertised = last_json_object(res.stdout)
517
+ self.assertIn("gate", advertised["input"]["properties"]["command"]["enum"])
518
+ for action in ("open", "settle", "approve", "waive", "escalate", "count"):
519
+ self.assertIn(action, advertised["input"]["properties"]["action"]["enum"])
520
+ self.assertIn("gate", advertised["description"])
521
+ # The Pi extension declares its own factory surface (thin passthrough
522
+ # to the same MCP tool): pin its enum by text — importing the Pi
523
+ # harness here would couple this suite to its host mocks.
524
+ pi_text = (PROJECT_ROOT / "extensions" / "pi" / "index.js").read_text(
525
+ encoding="utf-8"
526
+ )
527
+ pi_factory = pi_text[
528
+ pi_text.index("name: 'iumbtems_factory'") : pi_text.index(
529
+ "name: 'iumbtems_factory'"
530
+ )
531
+ + 1500
532
+ ]
533
+ self.assertIn("'gate'", pi_factory)
534
+ for action in ("open", "settle", "approve", "waive", "escalate", "count"):
535
+ self.assertIn(f"'{action}'", pi_factory)
536
+
537
+
538
+ if __name__ == "__main__":
539
+ unittest.main()