@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
@@ -300,36 +300,373 @@ class TestConfigValidatorParity(unittest.TestCase):
300
300
  names = {c["name"] for c in cases}
301
301
  self.assertIn("vert_domain_pack_empty", names)
302
302
  self.assertIn("vert_searxng_url_missing_scheme", names)
303
+ # R3: the integral-float-in-integer-field case must be in the corpus,
304
+ # or parity would be vacuous for int-vs-float typing.
305
+ self.assertIn("type_integral_float_in_integer_field", names)
303
306
 
304
307
  by_name = self._js_verdicts(cases)
308
+ # R3: lexeme-sensitive cases route the JS verdict through the TEXT
309
+ # entrypoint (`validateConfigText`), where the `2.0` lexeme survives.
310
+ # The object entrypoint (`validateConfig`) is lexeme-blind by design
311
+ # (live JS numbers carry no int/float distinction) and keeps its old
312
+ # verdict — see test_r3_integer_field_lexeme_typing for the boundary.
313
+ sensitive = {
314
+ c["name"]: json.dumps(c["config"])
315
+ for c in cases
316
+ if c.get("lexeme_sensitive")
317
+ }
318
+ text_verdicts = self._js_text_verdicts(sensitive) if sensitive else {}
305
319
  matches = 0
306
320
  for case in cases:
307
321
  with self.subTest(case=case["name"]):
308
322
  py_problems = validate(case["config"], CONFIG)
309
323
  py_valid = not py_problems
310
- js = by_name[case["name"]]
324
+ if case["name"] in text_verdicts:
325
+ js_valid = text_verdicts[case["name"]]["valid"]
326
+ js_problems = text_verdicts[case["name"]]["problems"]
327
+ else:
328
+ js = by_name[case["name"]]
329
+ js_valid = js["valid"]
330
+ js_problems = js["problems"]
311
331
  self.assertEqual(
312
- js["valid"],
332
+ js_valid,
313
333
  py_valid,
314
- f"{case['name']}: js={js} py={py_problems}",
334
+ f"{case['name']}: js={js_problems} py={py_problems}",
315
335
  )
316
336
  self.assertEqual(
317
- js["valid"],
337
+ js_valid,
318
338
  case["valid"],
319
339
  f"{case['name']}: both validators disagree with the fixture",
320
340
  )
321
341
  if not case["valid"] and case.get("expect"):
322
342
  self.assertTrue(
323
- any(case["expect"] in p for p in js["problems"]),
324
- f"{case['name']}: {case['expect']!r} not in {js['problems']}",
343
+ any(case["expect"] in p for p in js_problems),
344
+ f"{case['name']}: {case['expect']!r} not in {js_problems}",
325
345
  )
326
346
  self.assertTrue(
327
347
  any(case["expect"] in p for p in py_problems),
328
348
  f"{case['name']}: {case['expect']!r} not in {py_problems}",
329
349
  )
330
- matches += js["valid"] == py_valid
350
+ matches += js_valid == py_valid
331
351
  self.assertEqual(matches, len(cases), "parity must be 100%")
332
352
 
353
+ def _js_text_verdicts(self, case_texts):
354
+ """JS verdicts via the lexeme-aware text entrypoint (R3 routing)."""
355
+ import tempfile
356
+
357
+ with tempfile.NamedTemporaryFile(
358
+ "w", suffix=".json", delete=False, encoding="utf-8"
359
+ ) as f:
360
+ json.dump(case_texts, f)
361
+ tmp = f.name
362
+ try:
363
+ res = run_node(
364
+ f"""
365
+ import fs from "node:fs";
366
+ import {{ validateConfigText }} from "./plugins/opencode/config-io.js";
367
+ const cases = JSON.parse(fs.readFileSync({tmp!r}, "utf-8"));
368
+ const out = Object.entries(cases).map(([name, text]) => {{
369
+ const problems = validateConfigText(text);
370
+ return {{name, valid: problems.length === 0, problems}};
371
+ }});
372
+ console.log(JSON.stringify({{results: out}}));
373
+ """
374
+ )
375
+ finally:
376
+ Path(tmp).unlink(missing_ok=True)
377
+ self.assertEqual(res.returncode, 0, f"js validator run failed: {res.stderr}")
378
+ return {r["name"]: r for r in last_json_object(res.stdout)["results"]}
379
+
380
+ def test_r2_object_path_float_rendering(self):
381
+ """R2: P2 holds on the path saves use (`validateConfig`, no lexemes).
382
+
383
+ An integral JS number in a float/`number`-context node renders
384
+ `2.0`-style (matching Python `{data!r}` for float values); in an
385
+ integer-context node it renders `2`. Rendering only — verdicts
386
+ unchanged.
387
+ """
388
+ from runner.schema_validate import validate
389
+ from runner.schemas import CONFIG
390
+
391
+ res = run_node(
392
+ """
393
+ import { validateConfig } from "./plugins/opencode/config-io.js";
394
+ const floatMsg = validateConfig({ divergence_threshold: 2.0 });
395
+ const intMsg = validateConfig({ cache_ttl_days: 2 });
396
+ const intBad = validateConfig({ cache_ttl_days: -1.5 });
397
+ console.log(JSON.stringify({ floatMsg, intMsg, intBad }));
398
+ """
399
+ )
400
+ self.assertEqual(res.returncode, 0, res.stderr)
401
+ d = last_json_object(res.stdout)
402
+ # The tiebreak repro, byte-exact on the save path.
403
+ self.assertEqual(
404
+ d["floatMsg"],
405
+ ["divergence_threshold: value 2.0 is above maximum 1.0"],
406
+ )
407
+ self.assertEqual(
408
+ d["floatMsg"],
409
+ validate({"divergence_threshold": 2.0}, CONFIG),
410
+ )
411
+ # Integer context still renders `2`, and stays valid.
412
+ self.assertEqual(d["intMsg"], [])
413
+ # Fractional float in an integer field still rejected as float.
414
+ self.assertEqual(
415
+ d["intBad"],
416
+ validate({"cache_ttl_days": -1.5}, CONFIG),
417
+ )
418
+
419
+ def test_r3_integer_field_lexeme_typing(self):
420
+ """R3: text-path uses the lexeme for int-vs-float typing.
421
+
422
+ `{"cache_ttl_days": 2.0}` text is REJECTED like Python (`expected
423
+ integer|null, got float`); `{"cache_ttl_days": 2}` text stays valid.
424
+ The object path keeps its old verdict (lexemes unavailable there —
425
+ documented boundary, not a bug).
426
+ """
427
+ from runner.schema_validate import validate
428
+ from runner.schemas import CONFIG
429
+
430
+ res = run_node(
431
+ """
432
+ import { validateConfig, validateConfigText } from "./plugins/opencode/config-io.js";
433
+ console.log(JSON.stringify({
434
+ textFloat: validateConfigText('{"cache_ttl_days": 2.0}'),
435
+ textInt: validateConfigText('{"cache_ttl_days": 2}'),
436
+ objectFloat: validateConfig({ cache_ttl_days: 2.0 }),
437
+ }));
438
+ """
439
+ )
440
+ self.assertEqual(res.returncode, 0, res.stderr)
441
+ d = last_json_object(res.stdout)
442
+ self.assertEqual(
443
+ d["textFloat"],
444
+ ["cache_ttl_days: expected integer|null, got float"],
445
+ )
446
+ self.assertEqual(
447
+ d["textFloat"],
448
+ validate({"cache_ttl_days": 2.0}, CONFIG),
449
+ )
450
+ self.assertEqual(d["textInt"], [])
451
+ # Boundary: the object path cannot see the `2.0` lexeme (live JS
452
+ # number `2`), so it still accepts — verdicts unchanged there.
453
+ # WAIVER W21 (F3): this acceptance is by design; see `typeMatches`
454
+ # in plugins/opencode/config-io.js and .roadmap/06-parity/waivers.md.
455
+ self.assertEqual(d["objectFloat"], [])
456
+
457
+ def test_w20_object_path_number_context_float_rendering_waiver(self):
458
+ """W20 (F1) WAIVER: object-path int in a number field renders float-style.
459
+
460
+ WAIVER, not a bug: live JS `2` === `2.0` (one double), while Python
461
+ `int(2)` vs `float(2.0)` render `2` vs `2.0` — the message ambiguity
462
+ is INFORMATION-THEORETIC. R2 float-direction rendering is kept by
463
+ design (`validateConfig({divergence_threshold: 2})` reads `2.0`;
464
+ verdicts agree with Python: both reject). The text path
465
+ (`validateConfigText`) is the exact surface. See `pyReprData` in
466
+ plugins/opencode/config-io.js and .roadmap/06-parity/waivers.md W20.
467
+ TRIGGER: flip deliberately only via a future lexeme-aware entrypoint.
468
+ """
469
+ from runner.schema_validate import validate
470
+ from runner.schemas import CONFIG
471
+
472
+ res = run_node(
473
+ """
474
+ import { validateConfig, validateConfigText } from "./plugins/opencode/config-io.js";
475
+ console.log(JSON.stringify({
476
+ objectInt: validateConfig({ divergence_threshold: 2 }),
477
+ textInt: validateConfigText('{"divergence_threshold": 2}'),
478
+ textFloat: validateConfigText('{"divergence_threshold": 2.0}'),
479
+ }));
480
+ """
481
+ )
482
+ self.assertEqual(res.returncode, 0, res.stderr)
483
+ d = last_json_object(res.stdout)
484
+ # Documented object behavior: R2 renders the lexeme-blind integral
485
+ # value float-style. Assert the CURRENT strings so a future
486
+ # lexeme-aware entrypoint flips this deliberately, not silently.
487
+ self.assertEqual(
488
+ d["objectInt"],
489
+ ["divergence_threshold: value 2.0 is above maximum 1.0"],
490
+ )
491
+ # Text path is exact: int lexeme renders `2` like Python-on-int.
492
+ self.assertEqual(d["textInt"], validate({"divergence_threshold": 2}, CONFIG))
493
+ self.assertEqual(
494
+ d["textInt"],
495
+ ["divergence_threshold: value 2 is above maximum 1.0"],
496
+ )
497
+ # Text-path float lexeme renders `2.0` like Python-on-float.
498
+ self.assertEqual(
499
+ d["textFloat"], validate({"divergence_threshold": 2.0}, CONFIG)
500
+ )
501
+
502
+ def test_w22_exponential_rendering_waiver(self):
503
+ """W22 (F2) WAIVER: exponential float rendering diverges on BOTH paths.
504
+
505
+ WAIVER, not a bug: `1e-5` -> JS `String()` gives `0.00001` while
506
+ CPython `repr()` gives `1e-05` (and `1e16` -> `10000000000000000`
507
+ vs `1e+16`) — repr SELECTION thresholds differ, not just padding,
508
+ so padding is whack-a-mole per second opinion
509
+ ses_f0b4aa769ffeE0zpoe9la477dw. Do NOT pad. See `pyRepr` in
510
+ plugins/opencode/config-io.js, `validate` in
511
+ runner/schema_validate.py, and .roadmap/06-parity/waivers.md W22.
512
+ TRIGGER: port of CPython shortest-repr switching; revisit only if
513
+ exponential config values occur.
514
+ """
515
+ import json
516
+
517
+ from runner.schema_validate import validate
518
+
519
+ schema_text = '{"type": "number", "minimum": 0.001}'
520
+ schema = json.loads(schema_text)
521
+ py_problems = validate(json.loads("1e-5"), schema)
522
+ self.assertEqual(py_problems, ["<root>: value 1e-05 is below minimum 0.001"])
523
+ res = run_node(
524
+ """
525
+ import { parseJsonWithNumberLexemes, validateAgainstSchema } from "./plugins/opencode/config-io.js";
526
+ const schema = parseJsonWithNumberLexemes('{"type": "number", "minimum": 0.001}');
527
+ const data = parseJsonWithNumberLexemes('1e-5');
528
+ const problems = validateAgainstSchema(data.value, schema.value, '', {
529
+ data: data.lexemes, schema: schema.lexemes, schemaPath: '',
530
+ });
531
+ console.log(JSON.stringify({ problems }));
532
+ """
533
+ )
534
+ self.assertEqual(res.returncode, 0, res.stderr)
535
+ js_problems = last_json_object(res.stdout)["problems"]
536
+ # Pin the DOCUMENTED divergence (assert it differs, with the exact
537
+ # current JS string) so a future repr port flips it deliberately.
538
+ self.assertEqual(js_problems, ["<root>: value 0.00001 is below minimum 0.001"])
539
+ self.assertNotEqual(js_problems, py_problems)
540
+
541
+ def _js_messages(self, case_texts):
542
+ """Exact JS message lists via the lexeme-aware text entrypoint.
543
+
544
+ `case_texts` maps name -> JSON text of the config (lexemes intact:
545
+ `2.0` stays a float lexeme). `validateConfigText` parses with
546
+ `parseJsonWithNumberLexemes`, so integral floats render Python-`repr`
547
+ style (`0.0`, never `0`).
548
+ """
549
+ import tempfile
550
+
551
+ with tempfile.NamedTemporaryFile(
552
+ "w", suffix=".json", delete=False, encoding="utf-8"
553
+ ) as f:
554
+ json.dump(case_texts, f)
555
+ tmp = f.name
556
+ try:
557
+ res = run_node(
558
+ f"""
559
+ import fs from "node:fs";
560
+ import {{ validateConfigText }} from "./plugins/opencode/config-io.js";
561
+ const cases = JSON.parse(fs.readFileSync({tmp!r}, "utf-8"));
562
+ const out = Object.entries(cases).map(([name, text]) => ({{
563
+ name, problems: validateConfigText(text),
564
+ }}));
565
+ console.log(JSON.stringify({{results: out}}));
566
+ """
567
+ )
568
+ finally:
569
+ Path(tmp).unlink(missing_ok=True)
570
+ self.assertEqual(res.returncode, 0, f"js validator run failed: {res.stderr}")
571
+ return {
572
+ r["name"]: r["problems"] for r in last_json_object(res.stdout)["results"]
573
+ }
574
+
575
+ def test_js_python_messages_identical(self):
576
+ """H7: every corpus case yields byte-identical message STRINGS, not
577
+ just identical verdicts (Python `{value!r}` wins: `0.0`, not `0`)."""
578
+ from runner.schema_validate import validate
579
+ from runner.schemas import CONFIG
580
+
581
+ cases = json.loads(self.FIXTURE.read_text(encoding="utf-8"))["cases"]
582
+ names = {c["name"] for c in cases}
583
+ # The H7 float-lexeme cases must be in the corpus, or message parity
584
+ # would silently skip the unified rendering. R3 pins the int-vs-float
585
+ # typing case alongside them.
586
+ for required in (
587
+ "float_integral_divergence_above_maximum",
588
+ "float_integral_search_timeout_below_minimum",
589
+ "float_integral_min_fuzzy_above_maximum",
590
+ "float_fractional_cache_ttl_type_error",
591
+ "type_integral_float_in_integer_field",
592
+ ):
593
+ self.assertIn(required, names)
594
+ texts = {c["name"]: json.dumps(c["config"]) for c in cases}
595
+ js_messages = self._js_messages(texts)
596
+ for case in cases:
597
+ with self.subTest(case=case["name"]):
598
+ py_problems = validate(case["config"], CONFIG)
599
+ self.assertEqual(js_messages[case["name"]], py_problems)
600
+
601
+ def test_js_python_float_message_parity_synthetic(self):
602
+ """H7: minima/maxima/enum/pattern renderings with float lexemes.
603
+
604
+ Synthetic schemas (no CONFIG float enums exist) driven from the SAME
605
+ JSON text on both sides: integral floats (`2.0`), int lexemes in
606
+ float fields (`0.0` vs bound `1`), nested `items` bounds, and a
607
+ string pattern case.
608
+ """
609
+ import tempfile
610
+
611
+ from runner.schema_validate import validate
612
+
613
+ pairs = [
614
+ # Float bounds, integral + non-integral data.
615
+ (
616
+ '{"type": "number", "minimum": 0.0, "maximum": 1.0}',
617
+ ["2.0", "0.0", "-1.5", "0", "1"],
618
+ ),
619
+ # Float enum members (integral and fractional).
620
+ (
621
+ '{"type": "number", "enum": [0.0, 0.5, 1.0]}',
622
+ ["0.5", "0.75", "2.0", "1"],
623
+ ),
624
+ # Int bound under a float-typed schema: `0.0` vs `1`.
625
+ ('{"type": "number", "minimum": 1}', ["0.0", "0", "2.0"]),
626
+ # R4: a REAL root-level array — Python renders indices bare
627
+ # (`[1]`); the JS branch used to render `<root>[1]`.
628
+ (
629
+ '{"type": "array", "items": {"type": "number", "maximum": 1.0}}',
630
+ ["[0.5, 2.0]", "[0.0, 1.0]", "[]"],
631
+ ),
632
+ # Pattern rendering pins the string side of the policy.
633
+ ('{"type": "string", "pattern": "^[0-9]+$"}', ['"abc"', '"42"']),
634
+ ]
635
+ payload = [{"schema": schema, "data": data_list} for schema, data_list in pairs]
636
+ with tempfile.NamedTemporaryFile(
637
+ "w", suffix=".json", delete=False, encoding="utf-8"
638
+ ) as f:
639
+ json.dump(payload, f)
640
+ tmp = f.name
641
+ try:
642
+ res = run_node(
643
+ f"""
644
+ import fs from "node:fs";
645
+ import {{ parseJsonWithNumberLexemes, validateAgainstSchema }} from "./plugins/opencode/config-io.js";
646
+ const pairs = JSON.parse(fs.readFileSync({tmp!r}, "utf-8"));
647
+ const out = pairs.map((p) => {{
648
+ const schema = parseJsonWithNumberLexemes(p.schema);
649
+ return p.data.map((text) => {{
650
+ const data = parseJsonWithNumberLexemes(text);
651
+ return validateAgainstSchema(data.value, schema.value, '', {{
652
+ data: data.lexemes, schema: schema.lexemes, schemaPath: '',
653
+ }});
654
+ }});
655
+ }});
656
+ console.log(JSON.stringify({{results: out}}));
657
+ """
658
+ )
659
+ finally:
660
+ Path(tmp).unlink(missing_ok=True)
661
+ self.assertEqual(res.returncode, 0, f"js validator run failed: {res.stderr}")
662
+ js_results = last_json_object(res.stdout)["results"]
663
+ for (schema_text, data_list), js_problems_list in zip(pairs, js_results):
664
+ schema = json.loads(schema_text)
665
+ for data_text, js_problems in zip(data_list, js_problems_list):
666
+ with self.subTest(schema=schema_text, data=data_text):
667
+ py_problems = validate(json.loads(data_text), schema)
668
+ self.assertEqual(js_problems, py_problems)
669
+
333
670
  def test_js_fallback_defaults_match_canonical(self):
334
671
  """The schema-unreadable fallback must equal CONFIG_DEFAULTS."""
335
672
  from runner.schemas import CONFIG_DEFAULTS
@@ -348,7 +685,62 @@ class TestConfigToolCatalogSurface(unittest.TestCase):
348
685
  """B4: the plugin TOOL_CATALOG advertises the full canonical surface."""
349
686
 
350
687
  CONTROL_ARGS = {"base_dir", "show", "expected_hash", "expectedHash"}
351
- KEYWORDS = ("type", "enum", "minimum", "maximum", "minLength", "pattern")
688
+ # R1 twin of the canonical comparator (cf. COMPARE_KEYWORDS in
689
+ # runner/tests/test_config_schema.py): the same scalar keyword set —
690
+ # type/enum/min/max (minimum/maximum)/minLength/pattern/default — with
691
+ # items/additionalProperties recursing as schema nodes, so the JS twin
692
+ # cannot silently narrow what the Python comparator walks.
693
+ KEYWORDS = (
694
+ "type",
695
+ "enum",
696
+ "minimum",
697
+ "maximum",
698
+ "minLength",
699
+ "pattern",
700
+ "default",
701
+ )
702
+
703
+ # R1 accepted asymmetry (precedent pattern: the Accepted-asymmetry note at
704
+ # plugins/opencode/index.js:1116): the live TOOL_CATALOG omits `default`
705
+ # keywords — canonical defaults live in runner/schemas.py (mirrored to
706
+ # schemas/config.schema.json); the catalog is an input shape, not a
707
+ # defaults source, so syncing defaults into it would risk the host
708
+ # auto-injecting them. The twin therefore skips `default` comparison ONLY
709
+ # on these declared paths AND only when the advertised side omits the key
710
+ # entirely. Any other default drift still bites: a wrong advertised value
711
+ # on a listed path, or any default where the canonical node has none
712
+ # (undeclared asymmetry). Keep this set in sync with CONFIG defaults by
713
+ # hand — a new canonical default must be added here deliberately, never
714
+ # silently.
715
+ ACCEPTED_DEFAULT_OMISSIONS = frozenset(
716
+ [
717
+ "search_engine",
718
+ "max_iterations",
719
+ "divergence_threshold",
720
+ "mode",
721
+ "backend",
722
+ "cache_raw_markdown",
723
+ "cache_ttl_days",
724
+ "search_timeout_s",
725
+ "searxng_url",
726
+ "license_whitelist",
727
+ "output_dir",
728
+ "allocation",
729
+ "domain_pack",
730
+ "verify",
731
+ "verify.min_fuzzy_confidence",
732
+ "agents",
733
+ "agents.alpha.backend",
734
+ "agents.alpha.model",
735
+ "agents.alpha.opencode_agent",
736
+ "agents.beta.backend",
737
+ "agents.beta.model",
738
+ "agents.beta.opencode_agent",
739
+ "opencode_auto",
740
+ "opencode_agent",
741
+ "mcp_servers",
742
+ ]
743
+ )
352
744
 
353
745
  def _advertised(self):
354
746
  res = run_node(
@@ -361,20 +753,59 @@ class TestConfigToolCatalogSurface(unittest.TestCase):
361
753
  self.assertEqual(res.returncode, 0, res.stderr)
362
754
  return last_json_object(res.stdout)["input"]
363
755
 
756
+ def _compare_schema_node(self, canonical, advertised, path, problems):
757
+ """Deep-compare one schema node, recursing into subschemas (R1 twin).
758
+
759
+ Mirrors TestMcpConfigSurface._compare_schema_node: scalar KEYWORDS at
760
+ this node, `properties` recursing per key, `items` as `{path}[]` and
761
+ `additionalProperties` as `{path}.*`. A canonical subschema missing on
762
+ the advertised side is a drift report, never a silent skip. The sole
763
+ carve-out is the declared `default` omission (see
764
+ ACCEPTED_DEFAULT_OMISSIONS).
765
+ """
766
+ for keyword in self.KEYWORDS:
767
+ if (
768
+ keyword == "default"
769
+ and path in self.ACCEPTED_DEFAULT_OMISSIONS
770
+ and isinstance(advertised, dict)
771
+ and "default" not in advertised
772
+ ):
773
+ continue
774
+ if canonical.get(keyword) != (advertised or {}).get(keyword):
775
+ problems.append(
776
+ f"{path}: {keyword} {canonical.get(keyword)!r} != "
777
+ f"{(advertised or {}).get(keyword)!r}"
778
+ )
779
+ if isinstance(canonical.get("properties"), dict):
780
+ if not isinstance((advertised or {}).get("properties"), dict):
781
+ problems.append(f"{path}: properties schema missing on advertised side")
782
+ else:
783
+ self._compare_properties(
784
+ canonical, advertised, f"{path}." if path else "", problems
785
+ )
786
+ for keyword, suffix in (("items", "[]"), ("additionalProperties", ".*")):
787
+ sub_node = canonical.get(keyword)
788
+ if isinstance(sub_node, dict):
789
+ adv_node = (advertised or {}).get(keyword)
790
+ if not isinstance(adv_node, dict):
791
+ problems.append(
792
+ f"{path}{suffix}: {keyword} schema missing on advertised side"
793
+ )
794
+ else:
795
+ self._compare_schema_node(
796
+ sub_node, adv_node, f"{path}{suffix}", problems
797
+ )
798
+
364
799
  def _compare(self, canonical, advertised, prefix, problems):
800
+ self._compare_properties(canonical, advertised, prefix, problems)
801
+
802
+ def _compare_properties(self, canonical, advertised, prefix, problems):
365
803
  for key, sub in (canonical.get("properties") or {}).items():
366
804
  adv = (advertised.get("properties") or {}).get(key)
367
805
  if adv is None:
368
806
  problems.append(f"missing advertised key: {prefix}{key}")
369
807
  continue
370
- for keyword in self.KEYWORDS:
371
- if keyword in sub and sub.get(keyword) != adv.get(keyword):
372
- problems.append(
373
- f"{prefix}{key}: {keyword} {sub.get(keyword)!r} != "
374
- f"{adv.get(keyword)!r}"
375
- )
376
- if isinstance(sub.get("properties"), dict):
377
- self._compare(sub, adv, f"{prefix}{key}.", problems)
808
+ self._compare_schema_node(sub, adv, f"{prefix}{key}", problems)
378
809
 
379
810
  def test_plugin_catalog_covers_canonical_surface(self):
380
811
  artifact = json.loads(
@@ -401,6 +832,107 @@ class TestConfigToolCatalogSurface(unittest.TestCase):
401
832
  advertised["properties"]["expectedHash"]["description"],
402
833
  )
403
834
 
835
+ def test_twin_comparator_catches_undeclared_drift(self):
836
+ """R1: the twin bites on default/items drift outside the allowlist.
837
+
838
+ Each sub-case mutates a FRESH advertised surface exactly once, so a
839
+ green sub-case proves its own keyword is compared (never vacuous via
840
+ a shared broken fixture).
841
+ """
842
+ import copy
843
+
844
+ artifact = json.loads(
845
+ (PROJECT_ROOT / "schemas" / "config.schema.json").read_text(
846
+ encoding="utf-8"
847
+ )
848
+ )
849
+
850
+ def complained_of(mutator, needle):
851
+ advertised = copy.deepcopy(self._advertised())
852
+ mutator(advertised["properties"])
853
+ problems = []
854
+ self._compare(artifact, advertised, "", problems)
855
+ self.assertTrue(
856
+ any(needle in p for p in problems),
857
+ f"{needle!r} not in {problems}",
858
+ )
859
+
860
+ # Wrong advertised default on an allowlisted path still bites (the
861
+ # carve-out covers omission only, never a conflicting value).
862
+ complained_of(
863
+ lambda props: props["mode"].__setitem__("default", "scout"),
864
+ "mode: default",
865
+ )
866
+ # Undeclared asymmetry bites: the canonical role node carries no
867
+ # `default`, so an advertised one is drift, not the accepted omission.
868
+ complained_of(
869
+ lambda props: props["agents"]["properties"]["alpha"].__setitem__(
870
+ "default", {"backend": None}
871
+ ),
872
+ "agents.alpha: default",
873
+ )
874
+ # `items` drift bites (top-level and nested).
875
+ complained_of(
876
+ lambda props: props["license_whitelist"].__setitem__(
877
+ "items", {"type": "integer"}
878
+ ),
879
+ "license_whitelist[]: type",
880
+ )
881
+ complained_of(
882
+ lambda props: props["agents"]["properties"]["alpha"]["properties"][
883
+ "backend"
884
+ ].__setitem__("items", {"type": "integer"}),
885
+ "agents.alpha.backend[]: type",
886
+ )
887
+ complained_of(
888
+ lambda props: props["license_whitelist"].pop("items"),
889
+ "license_whitelist[]: items schema missing",
890
+ )
891
+ # `additionalProperties` drift bites.
892
+ complained_of(
893
+ lambda props: props["mcp_servers"].__setitem__(
894
+ "additionalProperties", {"type": "string"}
895
+ ),
896
+ "mcp_servers.*: type",
897
+ )
898
+ complained_of(
899
+ lambda props: props["mcp_servers"].pop("additionalProperties"),
900
+ "mcp_servers.*: additionalProperties schema missing",
901
+ )
902
+ # A dropped key still bites.
903
+ complained_of(
904
+ lambda props: props.pop("verify"),
905
+ "missing advertised key: verify",
906
+ )
907
+
908
+ def test_declared_default_asymmetry_passes(self):
909
+ """R1: the allowlisted `default` omissions — and ONLY they — pass."""
910
+ import copy
911
+
912
+ artifact = json.loads(
913
+ (PROJECT_ROOT / "schemas" / "config.schema.json").read_text(
914
+ encoding="utf-8"
915
+ )
916
+ )
917
+ advertised = copy.deepcopy(self._advertised())
918
+ # Every canonical default path is either present-and-equal or a
919
+ # declared omission; nothing else may differ on `default`.
920
+ problems = []
921
+ self._compare(artifact, advertised, "", problems)
922
+ self.assertEqual(
923
+ [p for p in problems if ": default" in p],
924
+ [],
925
+ )
926
+ # The allowlist skips omission only: restoring the CORRECT canonical
927
+ # default must compare equal (proves the skip is scoped to omission,
928
+ # not a blanket `default` ignore — a wrong value still bites, as
929
+ # test_twin_comparator_catches_undeclared_drift pins).
930
+ restored = copy.deepcopy(advertised)
931
+ restored["properties"]["mode"]["default"] = "research"
932
+ problems = []
933
+ self._compare(artifact, restored, "", problems)
934
+ self.assertEqual(problems, [])
935
+
404
936
 
405
937
  class TestWizardSlashRegistration(unittest.TestCase):
406
938
  """B1: `swarm-config` slash + palette entries on the mock host."""