@heretek-ai/epistemic-swarm 0.7.21 → 0.7.22

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 (91) 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 +1 -1
  6. package/plugins/antigravity/plugin.json +1 -1
  7. package/plugins/factory/skills/factory/SKILL.md +1 -1
  8. package/plugins/opencode/config-io.js +598 -31
  9. package/plugins/opencode/index.js +56 -15
  10. package/plugins/opencode/tui.js +115 -11
  11. package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
  12. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  29. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  30. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  31. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  32. package/runner/mcp_server.py +249 -81
  33. package/runner/refinement.py +5 -0
  34. package/runner/schema_validate.py +63 -1
  35. package/runner/schemas.py +9 -4
  36. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_backends.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_claude_plugin.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  63. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  64. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  65. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  66. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  67. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  68. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  69. package/runner/tests/fixtures/config_validation_cases.json +60 -0
  70. package/runner/tests/test_config_schema.py +119 -15
  71. package/runner/tests/test_hardening_contract.py +391 -0
  72. package/runner/tests/test_opencode_ux.py +232 -0
  73. package/runner/tests/test_opencode_v2_registrars.py +548 -16
  74. package/runner/tests/test_phase08_waiver_reopens.py +539 -0
  75. package/schemas/config.schema.json +4 -3
  76. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  77. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  78. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  79. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  80. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  81. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  82. package/skills/factory/SKILL.md +1 -1
  83. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  84. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  85. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  86. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  87. package/skills/research_cache/hasher.py +33 -8
  88. package/skills/swarm_config/SKILL.md +6 -1
  89. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  90. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  91. 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()
@@ -104,12 +104,12 @@
104
104
  "string",
105
105
  "null"
106
106
  ],
107
- "pattern": "^https?://",
107
+ "pattern": "^https?://[^\\s/]+\\S*$",
108
108
  "default": null,
109
109
  "x-label": "SearXNG URL",
110
110
  "x-scope": "retrieval",
111
111
  "x-restart": "next-run",
112
- "description": "Base URL of a self-hosted SearXNG; exported to spawned children as SEARXNG_URL when set (preflight also reads the env var). Must include an http:// or https:// scheme; null leaves the environment untouched."
112
+ "description": "Base URL of a self-hosted SearXNG; exported to spawned children as SEARXNG_URL when set (preflight also reads the env var). Must include an http:// or https:// scheme AND a host (`http://` with no host is rejected); null leaves the environment untouched."
113
113
  },
114
114
  "license_whitelist": {
115
115
  "type": "array",
@@ -154,11 +154,12 @@
154
154
  "null"
155
155
  ],
156
156
  "minLength": 1,
157
+ "pattern": "^\\S(.*\\S)?$",
157
158
  "default": null,
158
159
  "x-label": "Domain Pack",
159
160
  "x-scope": "audit",
160
161
  "x-restart": "next-run",
161
- "description": "Regulated Domain Pack id (config/domain_packs/) or null for the legacy constitution. The empty string is rejected; a pack id that does not resolve fails loudly when the constitution loads."
162
+ "description": "Regulated Domain Pack id (config/domain_packs/) or null for the legacy constitution. The empty string, whitespace-only strings, and ids with leading/trailing whitespace are rejected; a pack id that does not resolve fails loudly when the constitution loads. UI entry points (iumbtems_set_domain_pack) strip surrounding whitespace before resolving."
162
163
  },
163
164
  "verify": {
164
165
  "type": "object",
@@ -21,7 +21,7 @@ npm cache in consuming projects, and relative paths broke live: the manager
21
21
  agent ran `find / -name factory.py`). The tool wraps the same helper and
22
22
  resolves the project via `project_dir` argument, `IUMBTEMS_PROJECT_DIR`, or
23
23
  the session cwd. It supports `init`, `phase-add`, `qa-record`, `expansion`,
24
- `stop`, and returns `status: escalated` (exit 2) on the 3rd QA failure.
24
+ `stop`, `gate` (`--action open|settle|approve|waive|escalate|count`), and returns `status: escalated` (exit 2) on the 3rd QA failure.
25
25
 
26
26
  ## 2. Gate protocol (max 5 swarm cycles per gate)
27
27
 
@@ -17,6 +17,35 @@ from typing import Dict, Any, Optional, Tuple
17
17
  DEFAULT_RESEARCH_DIR = ".research"
18
18
  BASE_DIR_HELP = "Base .research directory"
19
19
 
20
+ #: Historical decision boundary for the fuzzy quote fallback (config
21
+ #: `verify.min_fuzzy_confidence`). This is the default whenever no threshold
22
+ #: is configured or the configured value is malformed.
23
+ DEFAULT_MIN_FUZZY_CONFIDENCE = 0.88
24
+
25
+
26
+ def coerce_min_fuzzy_confidence(value: Any) -> float:
27
+ """Coerce a `verify.min_fuzzy_confidence` candidate into [0, 1].
28
+
29
+ SINGLE-ENFORCEMENT-POINT CONTRACT (Phase 05 H5): this function is the one
30
+ definition of the coercion rule — string values are parsed with `float()`
31
+ (so a hand-edited `"0.95"` behaves like `0.95`), the result is clamped
32
+ into [0, 1], and anything malformed (None, non-numeric strings, NaN)
33
+ falls back to `DEFAULT_MIN_FUZZY_CONFIDENCE` (0.88, the strict
34
+ historical boundary). `SourceHasher.verify_quote` enforces it at call
35
+ time; every other layer (MCP `iumbtems_verify_quote`, the auditor, the
36
+ swarm runner) forwards its configured value untouched and must NOT
37
+ re-implement this rule. The persisted schema stays strict
38
+ (`type: number`): coercion exists only for hand-edited files and direct
39
+ library callers, never as an excuse to loosen validation.
40
+ """
41
+ try:
42
+ threshold = float(value)
43
+ except (TypeError, ValueError):
44
+ return DEFAULT_MIN_FUZZY_CONFIDENCE
45
+ if threshold != threshold: # NaN: float("nan") parses but matches nothing
46
+ return DEFAULT_MIN_FUZZY_CONFIDENCE
47
+ return max(0.0, min(1.0, threshold))
48
+
20
49
 
21
50
  def _log_retrieval(base_dir, kind: str, **fields) -> None:
22
51
  """Best-effort retrieval telemetry (see runner/retrieval_log.py)."""
@@ -146,15 +175,11 @@ class SourceHasher:
146
175
  the historical decision boundary; callers that pass a value opt into a
147
176
  stricter/looser match. Exact and normalized matches are unaffected.
148
177
 
149
- The value is clamped into [0, 1] here — the single enforcement point —
150
- so MCP/auditor layers may parse config freely; malformed input falls back
151
- to the strict default.
178
+ Coercion is NOT done here inline: `coerce_min_fuzzy_confidence` (the
179
+ single enforcement point — see its contract) parses, clamps, and
180
+ defaults the threshold.
152
181
  """
153
- try:
154
- threshold = float(min_fuzzy_confidence)
155
- except (TypeError, ValueError):
156
- threshold = 0.88
157
- threshold = max(0.0, min(1.0, threshold))
182
+ threshold = coerce_min_fuzzy_confidence(min_fuzzy_confidence)
158
183
 
159
184
  source_text = self.get_source_content(content_hash)
160
185
  if not source_text:
@@ -122,7 +122,12 @@ and enforced by `iumbtems_config` before any write. The write guard
122
122
  prefix of **at least 8 hex characters**; conflicting spellings are rejected with
123
123
  `CONFLICTING_EXPECTED_HASH`. `heal_config` refuses to persist a file that fails
124
124
  canonical validation (the legacy-pin migration stays in memory until the
125
- invalid values are fixed).
125
+ invalid values are fixed). Non-healing reads (phase-08 R3 truth): no production
126
+ read path calls `heal_config` — `--show` / bare CLI, the MCP `show` leg, and
127
+ `load_config` itself never write. The migration is applied in memory on every
128
+ load and persists opportunistically when a later write leg merges the loaded
129
+ config; `heal_config` remains an exported, tested, explicit repair helper for
130
+ persisting the migration outside any other write.
126
131
 
127
132
  ## 4. TUI Settings Surface (OpenCode V2)
128
133