@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
@@ -31,7 +31,8 @@
31
31
  * a foreign live lock is never removed, and a lock adopted away from us by
32
32
  * a concurrent takeover is left intact.
33
33
  * - Optional expected-hash guard (both `expected_hash` and `expectedHash`
34
- * spellings; both present must agree or the call is rejected with
34
+ * spellings; both present must agree after strip+lowercase normalization
35
+ * or the call is rejected with
35
36
  * CONFLICTING_EXPECTED_HASH). Full SHA-256 or a prefix of at least 8 hex
36
37
  * chars; a mismatch raises CONFIG_STALE with a fresh snapshot and writes
37
38
  * nothing. A malformed guard raises CONFIG_INVALID_EXPECTED_HASH.
@@ -42,12 +43,19 @@
42
43
 
43
44
  import { createHash, randomBytes } from 'node:crypto';
44
45
  import {
46
+ closeSync,
45
47
  existsSync,
48
+ fstatSync,
49
+ lstatSync,
46
50
  mkdirSync,
51
+ openSync,
47
52
  readFileSync,
53
+ readSync,
48
54
  renameSync,
55
+ statSync,
49
56
  unlinkSync,
50
57
  writeFileSync,
58
+ constants as fsConstants,
51
59
  } from 'node:fs';
52
60
  import path from 'node:path';
53
61
  import { fileURLToPath } from 'node:url';
@@ -167,6 +175,45 @@ export class ConfigHashError extends ConfigError {
167
175
  }
168
176
  }
169
177
 
178
+ /**
179
+ * A caller-supplied null targets a schema-known non-nullable key (R2).
180
+ * Structural unification: `saveConfig` throws this (NULL_FOR_NON_NULLABLE_KEY)
181
+ * instead of the generic CONFIG_VALIDATION_FAILED, mirroring the Python
182
+ * `save_config`/`_handle_config` pre-check. Scoping: only schema-known paths
183
+ * are reported; nulls under unknown keys/roles stay preserved-verbatim.
184
+ */
185
+ export class ConfigNullError extends ConfigError {
186
+ constructor(paths) {
187
+ const names = [...new Set((paths || []).map(String))].sort();
188
+ super(
189
+ 'null is not accepted for non-nullable key(s): ' +
190
+ names.join(', ') +
191
+ '; omit the key to leave it unchanged, or pass a valid value',
192
+ { errors: names }
193
+ );
194
+ this.name = 'ConfigNullError';
195
+ this.code = 'NULL_FOR_NON_NULLABLE_KEY';
196
+ }
197
+
198
+ get errors() {
199
+ return [...(this.details.errors || [])];
200
+ }
201
+ }
202
+
203
+ /**
204
+ * The config file exists but is not a readable, bounded regular file
205
+ * (phase-08 R2, W17). `saveConfig` throws this fail-closed (nothing written)
206
+ * instead of hanging on a FIFO/special device or silently merging over an
207
+ * unreadable/oversize file — mirroring the Python `ConfigUnreadableError`.
208
+ */
209
+ export class ConfigUnreadableError extends ConfigError {
210
+ constructor(message, details = {}) {
211
+ super(message, { written: false, ...(details || {}) });
212
+ this.name = 'ConfigUnreadableError';
213
+ this.code = 'CONFIG_UNREADABLE';
214
+ }
215
+ }
216
+
170
217
  /** A live `.config.lock` is held by another writer; nothing was written. */
171
218
  export class ConfigLockedError extends ConfigError {
172
219
  constructor(details = {}) {
@@ -187,6 +234,13 @@ export class ConfigLockedError extends ConfigError {
187
234
 
188
235
  let cachedSchema = null;
189
236
  let schemaAttempted = false;
237
+ /**
238
+ * H7: number lexemes (`0.0` vs `0`) of the canonical schema artifact, keyed
239
+ * by literal schema path (`properties.divergence_threshold.maximum`). Lets
240
+ * bound/enum renderings match Python `{bound!r}` exactly; `null` when the
241
+ * artifact is unreadable.
242
+ */
243
+ let cachedSchemaLexemes = null;
190
244
 
191
245
  function schemaArtifactPath() {
192
246
  return path.join(PKG_ROOT, 'schemas', 'config.schema.json');
@@ -202,8 +256,11 @@ export function loadConfigSchema() {
202
256
  schemaAttempted = true;
203
257
  try {
204
258
  const raw = readFileSync(schemaArtifactPath(), 'utf-8');
205
- const parsed = JSON.parse(raw);
206
- if (parsed && typeof parsed === 'object') cachedSchema = parsed;
259
+ const parsed = parseJsonWithNumberLexemes(raw);
260
+ if (parsed.value && typeof parsed.value === 'object') {
261
+ cachedSchema = parsed.value;
262
+ cachedSchemaLexemes = parsed.lexemes;
263
+ }
207
264
  } catch {
208
265
  try {
209
266
  console.error(
@@ -214,6 +271,7 @@ export function loadConfigSchema() {
214
271
  /* logging must never throw */
215
272
  }
216
273
  cachedSchema = null;
274
+ cachedSchemaLexemes = null;
217
275
  }
218
276
  return cachedSchema;
219
277
  }
@@ -222,8 +280,150 @@ export function loadConfigSchema() {
222
280
  export function _resetSchemaCache() {
223
281
  cachedSchema = null;
224
282
  schemaAttempted = false;
283
+ cachedSchemaLexemes = null;
225
284
  }
226
285
 
286
+ // ---------------------------------------------------------------------------
287
+ // lexeme-preserving JSON parse (H7 verbatim-lexeme policy)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Parse JSON text like `JSON.parse` but also record every number's raw lexeme.
292
+ *
293
+ * Returns `{ value, lexemes }` where `lexemes` maps a literal path to the
294
+ * source text of the number there: object keys join with `.` (`verify.min_fuzzy_confidence`),
295
+ * array indices append `[i]` (`license_whitelist[1]`), and the root is `''`
296
+ * (`[0]` for a root-level array element). Values are identical to
297
+ * `JSON.parse` (same doubles, same strings); only the lexeme table is extra.
298
+ * Throws `SyntaxError` on malformed JSON, mirroring `JSON.parse`.
299
+ */
300
+ export function parseJsonWithNumberLexemes(text) {
301
+ const src = String(text);
302
+ const lexemes = new Map();
303
+ let pos = 0;
304
+
305
+ const fail = (msg) => {
306
+ throw new SyntaxError(`${msg} at position ${pos}`);
307
+ };
308
+ const skipWs = () => {
309
+ while (pos < src.length) {
310
+ const ch = src[pos];
311
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') pos += 1;
312
+ else break;
313
+ }
314
+ };
315
+ const expect = (word) => {
316
+ if (src.startsWith(word, pos)) {
317
+ pos += word.length;
318
+ return;
319
+ }
320
+ fail(`expected ${word}`);
321
+ };
322
+ const parseString = () => {
323
+ const start = pos;
324
+ pos += 1; // opening quote
325
+ while (pos < src.length) {
326
+ const ch = src[pos];
327
+ if (ch === '\\') {
328
+ pos += 2;
329
+ continue;
330
+ }
331
+ if (ch === '"') {
332
+ pos += 1;
333
+ return JSON.parse(src.slice(start, pos));
334
+ }
335
+ pos += 1;
336
+ }
337
+ fail('unterminated string');
338
+ };
339
+ const parseNumber = (at) => {
340
+ const rest = src.slice(pos);
341
+ const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(rest);
342
+ if (!match) fail('expected value');
343
+ pos += match[0].length;
344
+ lexemes.set(at, match[0]);
345
+ return Number(match[0]);
346
+ };
347
+ const parseArray = (at) => {
348
+ pos += 1; // [
349
+ const out = [];
350
+ skipWs();
351
+ if (src[pos] === ']') {
352
+ pos += 1;
353
+ return out;
354
+ }
355
+ let i = 0;
356
+ for (;;) {
357
+ out.push(parseValue(`${at}[${i}]`));
358
+ i += 1;
359
+ skipWs();
360
+ if (src[pos] === ',') {
361
+ pos += 1;
362
+ continue;
363
+ }
364
+ if (src[pos] === ']') {
365
+ pos += 1;
366
+ return out;
367
+ }
368
+ fail('expected , or ]');
369
+ }
370
+ };
371
+ const parseObject = (at) => {
372
+ pos += 1; // {
373
+ const out = {};
374
+ skipWs();
375
+ if (src[pos] === '}') {
376
+ pos += 1;
377
+ return out;
378
+ }
379
+ for (;;) {
380
+ skipWs();
381
+ if (src[pos] !== '"') fail('expected string key');
382
+ const key = parseString();
383
+ skipWs();
384
+ if (src[pos] !== ':') fail('expected colon');
385
+ pos += 1;
386
+ out[key] = parseValue(at ? `${at}.${key}` : String(key));
387
+ skipWs();
388
+ if (src[pos] === ',') {
389
+ pos += 1;
390
+ continue;
391
+ }
392
+ if (src[pos] === '}') {
393
+ pos += 1;
394
+ return out;
395
+ }
396
+ fail('expected , or }');
397
+ }
398
+ };
399
+ const parseValue = (at) => {
400
+ skipWs();
401
+ if (pos >= src.length) fail('unexpected end');
402
+ const ch = src[pos];
403
+ if (ch === '{') return parseObject(at);
404
+ if (ch === '[') return parseArray(at);
405
+ if (ch === '"') return parseString();
406
+ if (ch === 't') {
407
+ expect('true');
408
+ return true;
409
+ }
410
+ if (ch === 'f') {
411
+ expect('false');
412
+ return false;
413
+ }
414
+ if (ch === 'n') {
415
+ expect('null');
416
+ return null;
417
+ }
418
+ return parseNumber(at);
419
+ };
420
+
421
+ const value = parseValue('');
422
+ skipWs();
423
+ if (pos !== src.length) fail('unexpected trailing content');
424
+ return { value, lexemes };
425
+ };
426
+
227
427
  // ---------------------------------------------------------------------------
228
428
  // dependency-free validator subset (port of runner/schema_validate.py)
229
429
  // ---------------------------------------------------------------------------
@@ -231,6 +431,21 @@ export function _resetSchemaCache() {
231
431
  function typeMatches(value, expected) {
232
432
  switch (expected) {
233
433
  case 'integer':
434
+ // Object-path rule (lexeme-blind): JSON.parse erases the int/float
435
+ // distinction, so an integral float (`2.0`) passes here while Python
436
+ // rejects it (`got float`). See typeMatchesLex for the text-path rule
437
+ // (R3); distinguishing on the object path would change save-path
438
+ // verdicts on info JS cannot recover, so it stays as is by design.
439
+ // WAIVER W21 (F3, Phase 06 `06-parity` REWORK retry 2/3, manager
440
+ // tiebreak binding): object-path integer verdict lexeme-blindness is
441
+ // KEPT by design — `validateConfig({cache_ttl_days: 2.0})` (live JS
442
+ // number `2`) accepts while Python `validate({"cache_ttl_days": 2.0})`
443
+ // rejects (`expected integer|null, got float`); the text path
444
+ // (`validateConfigText('{"cache_ttl_days": 2.0}')`) is the exact
445
+ // surface and is corpus-pinned. Safety: Python load path stays
446
+ // lenient, next Python write re-validates (low harm). TRIGGER: flip
447
+ // deliberately only via a lexeme-aware save entrypoint threading TUI
448
+ // text to the validator (see .roadmap/06-parity/waivers.md W21).
234
449
  return typeof value === 'number' && Number.isInteger(value);
235
450
  case 'number':
236
451
  // NaN passes the type check and is rejected by the finiteness check,
@@ -264,15 +479,77 @@ function typeName(value) {
264
479
  return typeof value;
265
480
  }
266
481
 
267
- /** Python repr() for scalars/collections (single quotes, True/None). */
268
- function pyRepr(value) {
482
+ /**
483
+ * R3: lexeme-aware integer typing for the TEXT path (`validateConfigText` /
484
+ * `validateAgainstSchema` with a data-lexeme table). `JSON.parse` erases the
485
+ * int/float distinction (`2.0` and `2` both become the double `2`), while
486
+ * Python's `json` keeps it — so `{"cache_ttl_days": 2.0}` is a float in
487
+ * Python (`expected integer|null, got float`) but looked like an int here.
488
+ * When the raw lexeme marks a float (`2.0`, `2e0`) but the value is
489
+ * integral, the value counts as a float: `integer` no longer matches and the
490
+ * type name renders `float`. With no lexeme (object path: `validateConfig` /
491
+ * `saveConfig` on live JS numbers) behavior is byte-identical to before —
492
+ * the int/float distinction is unrecoverable there (documented boundary;
493
+ * object-path verdicts unchanged by design).
494
+ */
495
+ function isFloatLexeme(lexeme) {
496
+ return typeof lexeme === 'string' && /[.eE]/.test(lexeme);
497
+ }
498
+
499
+ function typeMatchesLex(value, expected, lexeme = null) {
500
+ if (expected === 'integer') {
501
+ if (typeof value !== 'number' || !Number.isInteger(value)) return false;
502
+ if (isFloatLexeme(lexeme)) return false;
503
+ return true;
504
+ }
505
+ return typeMatches(value, expected);
506
+ }
507
+
508
+ /** R3: `float` wins when the lexeme marks an integral value as a float. */
509
+ function typeNameLex(value, lexeme = null) {
510
+ if (
511
+ typeof value === 'number' &&
512
+ Number.isInteger(value) &&
513
+ isFloatLexeme(lexeme)
514
+ ) {
515
+ return 'float';
516
+ }
517
+ return typeName(value);
518
+ }
519
+
520
+ /** Python repr() for scalars/collections (single quotes, True/None).
521
+ *
522
+ * H7 verbatim-lexeme policy (Python repr wins): JSON.parse erases the
523
+ * int/float distinction (`0.0` and `0` both become the double `0`), while
524
+ * Python's `json` keeps it (`{data!r}` renders `0.0` vs `0`). The optional
525
+ * `lexeme` is the raw JSON number text from `parseJsonWithNumberLexemes`;
526
+ * when it marks a float (`0.0`, `1.0`, `2e0`) but the value is integral,
527
+ * render Python-`repr` style (`0.0`, never `0`). Without a lexeme the
528
+ * fallback is plain `String(value)` — programmatic JS integral numbers
529
+ * correspond to Python ints, so default behavior is byte-identical.
530
+ *
531
+ * Residuals (documented, unavoidable without a lexeme): exponential lexemes
532
+ * render via `String()` (JS `1e-7` vs Python `1e-07`), and integers past
533
+ * 2^53 lose precision in both parsers differently (Python keeps the int).
534
+ * WAIVER W22 (F2, Phase 06 `06-parity` REWORK retry 2/3, manager tiebreak
535
+ * binding, second opinion agrees): the exponential float rendering class
536
+ * is WAIVED on BOTH paths — do NOT pad (whack-a-mole: `1e-5` -> JS
537
+ * `0.00001` vs Python `1e-05`, `1e16` -> JS `10000000000000000` vs Python
538
+ * `1e+16` differ by CPython shortest-repr SELECTION thresholds, not just
539
+ * zero-padding, so padding fixes one instance and breaks the next).
540
+ * Documented divergence pinned by
541
+ * `test_w22_exponential_rendering_waiver`. TRIGGER: revisit only via a
542
+ * port of CPython shortest-repr switching, and only if exponential config
543
+ * values occur (see .roadmap/06-parity/waivers.md W22).
544
+ */
545
+ function pyRepr(value, lexeme = null) {
269
546
  if (typeof value === 'string') {
270
547
  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
271
548
  }
272
549
  if (value === null || value === undefined) return 'None';
273
550
  if (value === true) return 'True';
274
551
  if (value === false) return 'False';
275
- if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
552
+ if (Array.isArray(value)) return `[${value.map((v) => pyRepr(v)).join(', ')}]`;
276
553
  if (typeof value === 'object') {
277
554
  const parts = Object.entries(value).map(
278
555
  ([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`
@@ -283,28 +560,135 @@ function pyRepr(value) {
283
560
  if (Number.isNaN(value)) return 'nan';
284
561
  if (value === Infinity) return 'inf';
285
562
  if (value === -Infinity) return '-inf';
563
+ if (
564
+ typeof lexeme === 'string' &&
565
+ /[.eE]/.test(lexeme) &&
566
+ Number.isInteger(value)
567
+ ) {
568
+ if (Object.is(value, -0)) return '-0.0';
569
+ const text = String(value);
570
+ if (text.includes('e') || text.includes('E') || text.includes('.')) {
571
+ return text;
572
+ }
573
+ return `${text}.0`;
574
+ }
286
575
  }
287
576
  return String(value);
288
577
  }
289
578
 
290
- function pyReprEnum(values) {
291
- return `[${(values || []).map(pyRepr).join(', ')}]`;
579
+ function pyReprEnum(values, lexemes = null) {
580
+ const list = values || [];
581
+ return `[${list.map((v, i) => pyRepr(v, lexemes ? lexemes[i] ?? null : null)).join(', ')}]`;
582
+ }
583
+
584
+ /**
585
+ * R2: schema-type-aware DATA rendering for the OBJECT path (`validateConfig`
586
+ * on live JS numbers — the path `saveConfig` uses). Without a lexeme an
587
+ * integral JS number is ambiguous (Python `2` is an int, `2.0` a float), so
588
+ * the schema node decides: a float/`number`-context node renders `2.0`-style
589
+ * (matching Python `{data!r}` for float values, e.g. `divergence_threshold:
590
+ * value 2.0 is above maximum 1.0`), while an integer-context node renders
591
+ * `2`. With a lexeme the verbatim text wins (H7 policy, unchanged).
592
+ * Rendering only — comparisons still use the parsed doubles, so no verdict
593
+ * changes in this fix. Schema-literal minima/maxima/enum keep their
594
+ * `loadConfigSchema` lexemes (never routed here).
595
+ * WAIVER W20 (F1, Phase 06 `06-parity` REWORK retry 2/3, manager tiebreak
596
+ * binding): object-path int-in-number-field message ambiguity is
597
+ * INFORMATION-THEORETIC and KEPT in the R2 float direction — live JS `2`
598
+ * === `2.0` (one double), while Python `int(2)` vs `float(2.0)` render
599
+ * `2` vs `2.0`, so `validateConfig({divergence_threshold: 2})` renders
600
+ * `value 2.0 is above maximum 1.0` where Python-on-int-`2` renders
601
+ * `value 2 is above maximum 1.0` (verdicts agree: both reject). The text
602
+ * path (`validateConfigText('{"divergence_threshold": 2}')` -> `2`,
603
+ * `... 2.0}` -> `2.0`) is the exact surface. Documented behavior pinned
604
+ * by `test_w20_object_path_number_context_float_rendering_waiver` — flip
605
+ * deliberately only via a future lexeme-aware entrypoint (see
606
+ * .roadmap/06-parity/waivers.md W20).
607
+ */
608
+ function isFloatContext(schemaNode) {
609
+ const t = schemaNode && schemaNode.type;
610
+ const types = Array.isArray(t) ? t : t === undefined ? [] : [t];
611
+ return types.includes('number') && !types.includes('integer');
612
+ }
613
+
614
+ function pyReprData(value, lexeme, schemaNode) {
615
+ if (
616
+ typeof value === 'number' &&
617
+ Number.isInteger(value) &&
618
+ (lexeme === null || lexeme === undefined) &&
619
+ isFloatContext(schemaNode)
620
+ ) {
621
+ if (Object.is(value, -0)) return '-0.0';
622
+ const text = String(value);
623
+ if (text.includes('.') || text.includes('e') || text.includes('E')) {
624
+ return text;
625
+ }
626
+ return `${text}.0`;
627
+ }
628
+ return pyRepr(value, lexeme);
292
629
  }
293
630
 
294
631
  /**
295
632
  * Human-readable violations (dotted paths); [] == valid. Ports
296
633
  * `runner/schema_validate.py:validate` keyword for keyword.
634
+ *
635
+ * H7: the optional 4th argument carries number lexemes for Python-`repr`
636
+ * rendering — `{ data, schema, schemaPath }` where `data`/`schema` are
637
+ * `parseJsonWithNumberLexemes` tables (either may be null) and `schemaPath`
638
+ * is the literal path of `schema` within its document root (`''` at the
639
+ * root; recursion extends it with `.properties.<key>` / `.items` /
640
+ * `.additionalProperties`). Number *comparisons* always use the parsed
641
+ * doubles, so verdicts are unchanged with or without lexemes (except the R3
642
+ * int-vs-float typing rule, which needs a data lexeme); only message
643
+ * strings gain the `0.0` style. Omitted (or null) keeps the historical
644
+ * rendering for schema bounds, while DATA numbers without a lexeme render
645
+ * schema-type-aware (R2: `number`-context `2` reads `2.0`, integer-context
646
+ * reads `2`).
297
647
  */
298
- export function validateAgainstSchema(data, schema, currentPath = '') {
648
+ export function validateAgainstSchema(data, schema, currentPath = '', lex = null) {
299
649
  if (!schema || typeof schema !== 'object') return [];
300
650
  const problems = [];
301
651
  const at = currentPath || '<root>';
652
+ const schemaPath =
653
+ lex && typeof lex.schemaPath === 'string' ? lex.schemaPath : '';
654
+ const childLex = (childSchemaPath) =>
655
+ lex ? { data: lex.data, schema: lex.schema, schemaPath: childSchemaPath } : null;
656
+ const propSchemaPath = (key) =>
657
+ schemaPath ? `${schemaPath}.properties.${key}` : `properties.${key}`;
658
+ const lookupDataLexeme = (dataPath) => {
659
+ if (!lex || !(lex.data instanceof Map)) return null;
660
+ if (lex.data.has(dataPath)) return lex.data.get(dataPath);
661
+ // Legacy display-prefix fallback: root-level arrays used to display as
662
+ // `<root>[i]` while the lexeme table keys them `[i]` (R4 now renders
663
+ // `[i]` directly, so this only fires for callers holding old paths).
664
+ if (dataPath.startsWith('<root>')) {
665
+ const stripped = dataPath.slice('<root>'.length);
666
+ if (lex.data.has(stripped)) return lex.data.get(stripped);
667
+ }
668
+ return null;
669
+ };
670
+ const lookupSchemaLexeme = (keyword) => {
671
+ if (!lex || !(lex.schema instanceof Map)) return null;
672
+ const key = schemaPath ? `${schemaPath}.${keyword}` : keyword;
673
+ return lex.schema.has(key) ? lex.schema.get(key) : null;
674
+ };
675
+ const lookupEnumLexemes = (count) => {
676
+ if (!lex || !(lex.schema instanceof Map)) return null;
677
+ const base = schemaPath ? `${schemaPath}.enum` : 'enum';
678
+ return Array.from({ length: count }, (_, i) => {
679
+ const key = `${base}[${i}]`;
680
+ return lex.schema.has(key) ? lex.schema.get(key) : null;
681
+ });
682
+ };
683
+ const dataLexeme = lookupDataLexeme(currentPath);
302
684
 
303
685
  const expected = schema.type;
304
686
  if (expected !== undefined) {
305
687
  const types = Array.isArray(expected) ? expected : [expected];
306
- if (!types.some((t) => typeMatches(data, t))) {
307
- return [`${at}: expected ${types.join('|')}, got ${typeName(data)}`];
688
+ // R3: lexeme-aware integer typing on the text path (object path passes
689
+ // lexeme null, so its verdicts are unchanged by design).
690
+ if (!types.some((t) => typeMatchesLex(data, t, dataLexeme))) {
691
+ return [`${at}: expected ${types.join('|')}, got ${typeNameLex(data, dataLexeme)}`];
308
692
  }
309
693
  }
310
694
 
@@ -318,46 +702,57 @@ export function validateAgainstSchema(data, schema, currentPath = '') {
318
702
  for (const key of Object.keys(props)) {
319
703
  if (Object.prototype.hasOwnProperty.call(data, key)) {
320
704
  const child = currentPath ? `${currentPath}.${key}` : key;
321
- problems.push(...validateAgainstSchema(data[key], props[key], child));
705
+ problems.push(
706
+ ...validateAgainstSchema(data[key], props[key], child, childLex(propSchemaPath(key)))
707
+ );
322
708
  }
323
709
  }
324
710
  if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
325
711
  const known = new Set(Object.keys(props));
712
+ const childSchemaPath = schemaPath
713
+ ? `${schemaPath}.additionalProperties`
714
+ : 'additionalProperties';
326
715
  for (const key of Object.keys(data)) {
327
716
  if (!known.has(key)) {
328
717
  const child = currentPath ? `${currentPath}.${key}` : key;
329
718
  problems.push(
330
- ...validateAgainstSchema(data[key], schema.additionalProperties, child)
719
+ ...validateAgainstSchema(data[key], schema.additionalProperties, child, childLex(childSchemaPath))
331
720
  );
332
721
  }
333
722
  }
334
723
  }
335
724
  if (schema.enum !== undefined && !(schema.enum || []).includes(data)) {
336
- problems.push(`${at}: value ${pyRepr(data)} not in enum ${pyReprEnum(schema.enum)}`);
725
+ problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} not in enum ${pyReprEnum(schema.enum, lookupEnumLexemes((schema.enum || []).length))}`);
337
726
  }
338
727
  }
339
728
 
340
729
  if (Array.isArray(data) && schema.items && typeof schema.items === 'object') {
730
+ const childSchemaPath = schemaPath ? `${schemaPath}.items` : 'items';
341
731
  for (let i = 0; i < data.length; i += 1) {
342
- problems.push(...validateAgainstSchema(data[i], schema.items, `${at}[${i}]`));
732
+ // R4: Python renders root-level indices bare (`[1]`); building from the
733
+ // display prefix gave `<root>[1]`. Build from the DATA path ('' at the
734
+ // root), so top-level arrays agree byte-for-byte; nested paths are
735
+ // unchanged (`tags[0]`, `[0][1]`).
736
+ const child = currentPath ? `${at}[${i}]` : `[${i}]`;
737
+ problems.push(...validateAgainstSchema(data[i], schema.items, child, childLex(childSchemaPath)));
343
738
  }
344
739
  }
345
740
 
346
741
  if (data === null || typeof data !== 'object') {
347
742
  if (schema.enum !== undefined && !(schema.enum || []).includes(data)) {
348
- problems.push(`${at}: value ${pyRepr(data)} not in enum ${pyReprEnum(schema.enum)}`);
743
+ problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} not in enum ${pyReprEnum(schema.enum, lookupEnumLexemes((schema.enum || []).length))}`);
349
744
  }
350
745
  }
351
746
 
352
747
  if (typeof data === 'number') {
353
748
  if (!Number.isFinite(data)) {
354
- problems.push(`${at}: value ${pyRepr(data)} is not finite`);
749
+ problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} is not finite`);
355
750
  } else {
356
751
  if (schema.minimum !== undefined && data < schema.minimum) {
357
- problems.push(`${at}: value ${pyRepr(data)} is below minimum ${pyRepr(schema.minimum)}`);
752
+ problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} is below minimum ${pyRepr(schema.minimum, lookupSchemaLexeme('minimum'))}`);
358
753
  }
359
754
  if (schema.maximum !== undefined && data > schema.maximum) {
360
- problems.push(`${at}: value ${pyRepr(data)} is above maximum ${pyRepr(schema.maximum)}`);
755
+ problems.push(`${at}: value ${pyReprData(data, dataLexeme, schema)} is above maximum ${pyRepr(schema.maximum, lookupSchemaLexeme('maximum'))}`);
361
756
  }
362
757
  }
363
758
  }
@@ -375,7 +770,7 @@ export function validateAgainstSchema(data, schema, currentPath = '') {
375
770
  }
376
771
  if (!matched) {
377
772
  problems.push(
378
- `${at}: value ${pyRepr(data)} does not match pattern ${pyRepr(schema.pattern)}`
773
+ `${at}: value ${pyRepr(data, dataLexeme)} does not match pattern ${pyRepr(schema.pattern)}`
379
774
  );
380
775
  }
381
776
  }
@@ -389,7 +784,95 @@ export function validateConfig(cfg) {
389
784
  try {
390
785
  const schema = loadConfigSchema();
391
786
  if (!schema) return [];
392
- return validateAgainstSchema(cfg, schema);
787
+ const lex =
788
+ cachedSchemaLexemes instanceof Map
789
+ ? { data: null, schema: cachedSchemaLexemes, schemaPath: '' }
790
+ : null;
791
+ return validateAgainstSchema(cfg, schema, '', lex);
792
+ } catch {
793
+ return [];
794
+ }
795
+ }
796
+
797
+ /**
798
+ * H7: validate canonical-CONFIG JSON TEXT with verbatim lexemes.
799
+ *
800
+ * Parses `jsonText` with `parseJsonWithNumberLexemes` so float lexemes
801
+ * (`0.0`) survive erasure and messages match Python `{value!r}` exactly
802
+ * (verdicts are identical to `validateConfig` on the same text). Throws
803
+ * `SyntaxError` on malformed JSON, mirroring `JSON.parse`.
804
+ */
805
+ export function validateConfigText(jsonText) {
806
+ const { value, lexemes } = parseJsonWithNumberLexemes(jsonText);
807
+ const schema = loadConfigSchema();
808
+ if (!schema) return [];
809
+ return validateAgainstSchema(value, schema, '', {
810
+ data: lexemes,
811
+ schema: cachedSchemaLexemes,
812
+ schemaPath: '',
813
+ });
814
+ }
815
+
816
+ function schemaAllowsNull(subschema) {
817
+ if (!subschema || typeof subschema !== 'object') return false;
818
+ const t = subschema.type;
819
+ if (Array.isArray(t)) return t.includes('null');
820
+ return t === 'null';
821
+ }
822
+
823
+ /**
824
+ * Dotted paths in caller-supplied `caller` where null is banned (R2).
825
+ * Shared nested-null pre-check: `saveConfig` throws `ConfigNullError` for
826
+ * these paths so ALL writers emit `NULL_FOR_NON_NULLABLE_KEY` for
827
+ * schema-known paths. Only schema-known paths are reported; nulls under
828
+ * unknown top-level keys or unknown roles (e.g. `agents.gamma`) carry no
829
+ * nullability info and are preserved-verbatim. Returns [] when the schema
830
+ * is unreadable. Never throws.
831
+ */
832
+ export function findCallerNullViolations(caller) {
833
+ try {
834
+ if (!caller || typeof caller !== 'object' || Array.isArray(caller)) return [];
835
+ const schema = loadConfigSchema();
836
+ if (!schema || !schema.properties) return [];
837
+ const props = schema.properties;
838
+ const found = [];
839
+ const walk = (value, subschema, at) => {
840
+ if (value === null || value === undefined) {
841
+ if (subschema && typeof subschema === 'object' && !schemaAllowsNull(subschema)) {
842
+ found.push(at);
843
+ }
844
+ return;
845
+ }
846
+ if (value && typeof value === 'object' && !Array.isArray(value) && subschema && typeof subschema === 'object') {
847
+ const subProps = subschema.properties || {};
848
+ const additional = subschema.additionalProperties;
849
+ for (const [k, child] of Object.entries(value)) {
850
+ if (Object.prototype.hasOwnProperty.call(subProps, k)) {
851
+ walk(child, subProps[k], at ? `${at}.${k}` : String(k));
852
+ } else if (additional && typeof additional === 'object') {
853
+ walk(child, additional, at ? `${at}.${k}` : String(k));
854
+ }
855
+ }
856
+ return;
857
+ }
858
+ if (Array.isArray(value) && subschema && typeof subschema === 'object') {
859
+ const items = subschema.items;
860
+ if (items && typeof items === 'object') {
861
+ for (let i = 0; i < value.length; i += 1) {
862
+ walk(value[i], items, `${at}[${i}]`);
863
+ }
864
+ }
865
+ }
866
+ };
867
+ for (const [key, value] of Object.entries(caller)) {
868
+ if (!Object.prototype.hasOwnProperty.call(props, key)) continue;
869
+ if (value === null || value === undefined) {
870
+ if (!schemaAllowsNull(props[key])) found.push(String(key));
871
+ } else if (value && typeof value === 'object') {
872
+ walk(value, props[key], String(key));
873
+ }
874
+ }
875
+ return [...new Set(found)].sort();
393
876
  } catch {
394
877
  return [];
395
878
  }
@@ -540,13 +1023,61 @@ export function getLockPath(baseDir = DEFAULT_RESEARCH_DIR) {
540
1023
  return path.join(String(baseDir || DEFAULT_RESEARCH_DIR), CONFIG_LOCK_NAME);
541
1024
  }
542
1025
 
543
- function readBytes(file) {
1026
+ /** Cap for any single `config.json` read (mirrors the Python `CONFIG_MAX_BYTES`). */
1027
+ export const CONFIG_MAX_BYTES = 1024 * 1024;
1028
+
1029
+ function readBytes(file, maxBytes = CONFIG_MAX_BYTES) {
1030
+ // Phase-08 R2 (W17): a FIFO named `config.json` hung every reader on a
1031
+ // plain `readFileSync`. Port of the `runner/preflight.py`
1032
+ // `_read_bounded_text` semantics: refuse non-regular paths (`stat` +
1033
+ // post-open `fstat`, closing the stat->open race), open `O_NONBLOCK`, and
1034
+ // cap the read — so a pathological workspace entry (FIFO, device, socket,
1035
+ // directory, runaway file) can never block the TUI/plugin. Returns null
1036
+ // when the path is absent, unreadable, non-regular, or over the cap.
1037
+ // Symlinks are FOLLOWED (behavior-preserving: the pre-fix reader followed
1038
+ // them too); a symlink to a non-regular target is still refused.
544
1039
  try {
545
- if (!existsSync(file)) return null;
546
- return readFileSync(file);
1040
+ if (!statSync(file).isFile()) return null;
547
1041
  } catch {
548
1042
  return null;
549
1043
  }
1044
+ let fd = null;
1045
+ try {
1046
+ fd = openSync(file, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
1047
+ } catch {
1048
+ return null;
1049
+ }
1050
+ try {
1051
+ try {
1052
+ if (!fstatSync(fd).isFile()) return null;
1053
+ } catch {
1054
+ return null;
1055
+ }
1056
+ const chunks = [];
1057
+ let remaining = maxBytes + 1;
1058
+ const buf = Buffer.alloc(Math.min(remaining, 65536));
1059
+ for (;;) {
1060
+ let n;
1061
+ try {
1062
+ n = readSync(fd, buf, 0, Math.min(remaining, buf.length), null);
1063
+ } catch {
1064
+ return null;
1065
+ }
1066
+ if (!n) break;
1067
+ chunks.push(buf.subarray(0, n).toString('binary'));
1068
+ remaining -= n;
1069
+ if (remaining <= 0) break;
1070
+ }
1071
+ const data = Buffer.from(chunks.join(''), 'binary');
1072
+ if (data.length > maxBytes) return null;
1073
+ return data;
1074
+ } finally {
1075
+ try {
1076
+ if (fd !== null) closeSync(fd);
1077
+ } catch {
1078
+ /* close is best-effort */
1079
+ }
1080
+ }
550
1081
  }
551
1082
 
552
1083
  /** SHA-256 of the config file's exact bytes, or null when absent. */
@@ -873,15 +1404,23 @@ export function normalizeExpectedHash(expectedHash) {
873
1404
 
874
1405
  /**
875
1406
  * Resolve the guard from `expected_hash`/`expectedHash` spellings. Both
876
- * present must agree (mirror the MCP R4 rule) or a CONFLICTING_EXPECTED_HASH
877
- * ConfigError is raised and nothing is written.
1407
+ * present must agree AFTER normalization (strip + lowercase, mirroring
1408
+ * normalizeExpectedHash — Phase-05 H2) or a CONFLICTING_EXPECTED_HASH
1409
+ * ConfigError is raised and nothing is written. Case/whitespace-only
1410
+ * differences are one guard; empty/whitespace-only spellings count as absent
1411
+ * on both sides (R3: "" never conflicts); the winner is validated by
1412
+ * normalizeExpectedHash below (malformed -> ConfigHashError).
878
1413
  */
879
1414
  export function resolveExpectedHash(opts = {}) {
880
1415
  const snake = opts ? opts.expected_hash : undefined;
881
1416
  const camel = opts ? opts.expectedHash : undefined;
882
- const hasSnake = snake !== null && snake !== undefined;
883
- const hasCamel = camel !== null && camel !== undefined;
884
- if (hasSnake && hasCamel && String(snake) !== String(camel)) {
1417
+ const hasSnake = snake !== null && snake !== undefined && String(snake).trim() !== '';
1418
+ const hasCamel = camel !== null && camel !== undefined && String(camel).trim() !== '';
1419
+ if (
1420
+ hasSnake &&
1421
+ hasCamel &&
1422
+ String(snake).trim().toLowerCase() !== String(camel).trim().toLowerCase()
1423
+ ) {
885
1424
  throw new ConfigError(
886
1425
  'expected_hash and expectedHash were both provided with different ' +
887
1426
  'values; provide one spelling (or two equal values).',
@@ -993,10 +1532,15 @@ let tempCounter = 0;
993
1532
  * Atomically persist cfg into `<baseDir>/config.json`.
994
1533
  *
995
1534
  * - Merges over whatever is on disk (unknown keys survive); cfg wins.
1535
+ * - Caller-supplied null for a schema-known non-nullable key (any depth)
1536
+ * throws ConfigNullError (NULL_FOR_NON_NULLABLE_KEY) with dotted paths
1537
+ * before anything is written — the same code as the Python writer; nulls
1538
+ * under unknown keys/roles stay preserved-verbatim for generic validation.
996
1539
  * - Validates the merged dict (disable only with validate:false when the
997
- * exact dict was already validated); failures raise
1540
+ * exact dict was already validated); other failures raise
998
1541
  * ConfigValidationError and write nothing.
999
- * - Honors the expected-hash guard (both spellings); mismatches raise
1542
+ * - Honors the expected-hash guard (both spellings; empty/whitespace-only
1543
+ * spellings count as absent); mismatches raise
1000
1544
  * ConfigStaleError with a fresh snapshot (no absolute path, mirroring the
1001
1545
  * Python R5 rule) and write nothing.
1002
1546
  * - Publishes via a unique temp file + rename; `injectFailure:
@@ -1009,6 +1553,10 @@ export function saveConfig(cfg, baseDir = DEFAULT_RESEARCH_DIR, opts = {}) {
1009
1553
  `<root>: expected object, got ${typeName(cfg)} (config must be a JSON object)`,
1010
1554
  ]);
1011
1555
  }
1556
+ const nullPaths = findCallerNullViolations(cfg);
1557
+ if (nullPaths.length > 0) {
1558
+ throw new ConfigNullError(nullPaths);
1559
+ }
1012
1560
  const expected = resolveExpectedHash(opts || {});
1013
1561
  const dir = String(baseDir || DEFAULT_RESEARCH_DIR);
1014
1562
  mkdirSync(dir, { recursive: true });
@@ -1017,6 +1565,25 @@ export function saveConfig(cfg, baseDir = DEFAULT_RESEARCH_DIR, opts = {}) {
1017
1565
  let tmp = null;
1018
1566
  try {
1019
1567
  const existing = readBytes(cfgFile);
1568
+ let lexists = false;
1569
+ try {
1570
+ lstatSync(cfgFile);
1571
+ lexists = true;
1572
+ } catch {
1573
+ lexists = false;
1574
+ }
1575
+ if (existing === null && lexists) {
1576
+ // Phase-08 R2 (W17): fail closed (nothing written) rather than hang
1577
+ // on a FIFO/special device or silently merge over an
1578
+ // unreadable/oversize file — mirrors the Python save_config guard
1579
+ // (which also refuses non-file entries such as directories and
1580
+ // dangling symlinks instead of replacing them via os.replace).
1581
+ throw new ConfigUnreadableError(
1582
+ `${cfgFile} exists but is not a readable, bounded regular file ` +
1583
+ '(FIFO, device, directory, dangling symlink, or over the read cap); ' +
1584
+ 'refusing to merge over it.'
1585
+ );
1586
+ }
1020
1587
  const currentHash = existing
1021
1588
  ? createHash('sha256').update(existing).digest('hex')
1022
1589
  : null;