@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
@@ -152,13 +152,13 @@ export const TOOL_CATALOG = [
152
152
  show: {
153
153
  type: 'boolean',
154
154
  description:
155
- 'Inspect only; equivalent to calling with no update keys (reads are always safe).',
155
+ 'Inspect only; equivalent to calling with no update keys (reads are always safe). When true, the call is read-only even if update keys are present (they are reported as ignored and nothing is written; the expected_hash guard, including the dual-spelling conflict check, is not evaluated).',
156
156
  default: false,
157
157
  },
158
158
  expected_hash: {
159
159
  type: 'string',
160
160
  description:
161
- 'Optimistic-concurrency guard: SHA-256 (or a unique prefix of at least 8 hex chars) of the config file you read. A mismatch returns a structured stale error with a fresh snapshot and writes nothing. Alias: `expectedHash`; if both spellings are present they must be equal or the call is rejected.',
161
+ 'Optimistic-concurrency guard: SHA-256 (or a unique prefix of at least 8 hex chars) of the config file you read. A mismatch returns a structured stale error with a fresh snapshot and writes nothing. Empty/whitespace-only spellings count as absent. Alias: `expectedHash`; if both spellings are present they must be equal after normalization (strip + lowercase) or the call is rejected.',
162
162
  },
163
163
  expectedHash: {
164
164
  type: 'string',
@@ -207,8 +207,8 @@ export const TOOL_CATALOG = [
207
207
  },
208
208
  searxng_url: {
209
209
  type: ['string', 'null'],
210
- pattern: '^https?://',
211
- description: 'Base URL of a self-hosted SearXNG, exported as SEARXNG_URL when set; must include an http:// or https:// scheme; null leaves the environment untouched.',
210
+ pattern: '^https?://[^\\s/]+\\S*$',
211
+ description: 'Base URL of a self-hosted SearXNG, exported as SEARXNG_URL when set; must include an http:// or https:// scheme and a host, with no whitespace anywhere; null leaves the environment untouched.',
212
212
  },
213
213
  license_whitelist: {
214
214
  type: 'array',
@@ -227,7 +227,8 @@ export const TOOL_CATALOG = [
227
227
  domain_pack: {
228
228
  type: ['string', 'null'],
229
229
  minLength: 1,
230
- description: 'Regulated Domain Pack id (config/domain_packs/) or null for the legacy constitution; the empty string is rejected.',
230
+ pattern: '^\\S(.*\\S)?$',
231
+ 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.',
231
232
  },
232
233
  verify: {
233
234
  type: 'object',
@@ -440,13 +441,18 @@ export const TOOL_CATALOG = [
440
441
  {
441
442
  name: 'iumbtems_factory',
442
443
  description:
443
- 'Drive factory run state: init / phase-add / qa-record / expansion / stop. State goes to <project>/.factory and <project>/.roadmap; no helper-script path needed.',
444
+ 'Drive factory run state: init / phase-add / qa-record / expansion / stop / gate. State goes to <project>/.factory and <project>/.roadmap; no helper-script path needed.',
444
445
  input: {
445
446
  type: 'object',
446
447
  properties: {
447
448
  command: {
448
449
  type: 'string',
449
- enum: ['init', 'phase-add', 'qa-record', 'expansion', 'stop'],
450
+ enum: ['init', 'phase-add', 'qa-record', 'expansion', 'stop', 'gate'],
451
+ },
452
+ action: {
453
+ type: 'string',
454
+ enum: ['open', 'settle', 'approve', 'waive', 'escalate', 'count'],
455
+ description: 'Gate action (with command: gate)',
450
456
  },
451
457
  run: { type: 'string', description: 'Factory run name' },
452
458
  phase: { type: 'string', description: 'Phase id (e.g. 01-auth)' },
@@ -2227,13 +2233,16 @@ function rpcFail(context, type, message, data) {
2227
2233
  const SETTINGS_CONTROL_ARGS = new Set(['base_dir', 'dir', 'show', 'expected_hash', 'expectedHash']);
2228
2234
 
2229
2235
  /**
2230
- * Leak signatures that must never reach the RPC transport (phase-03 R7/R9):
2231
- * tracebacks, `File "…", line N` frames, interpreter failure lines, and
2232
- * absolute filesystem paths of ANY root — POSIX (e.g. `/workspace/…`,
2233
- * `/srv/…`, `/Users/…`, `/app/…`), Windows drive (`C:\Users\…`), and UNC
2234
- * (`\\server\share`). Structured validation problems (e.g. `mode: value … not
2235
- * in enum …`, type/range violations carrying ordinary values) match none of
2236
- * these and pass through intact.
2236
+ * Leak signatures that must never reach the RPC transport (phase-03 R7/R9,
2237
+ * phase-08 R1): tracebacks, `File "…", line N` frames, interpreter failure
2238
+ * lines, `file://` URIs, and absolute filesystem paths of ANY root — POSIX
2239
+ * multi-segment (e.g. `/workspace/…`, `/srv/…`, `/Users/…`, `/app/…`),
2240
+ * single-segment system roots (`/etc`, `/tmp`, `/lib`, `/lib64`, `/boot`,
2241
+ * `/snap`, macOS `/System`, `/Library`, `/Volumes`, `/Applications`, …),
2242
+ * Windows drive
2243
+ * (`C:\Users\…`), and UNC (`\\server\share`). Structured validation problems
2244
+ * (e.g. `mode: value … not in enum …`, type/range violations carrying
2245
+ * ordinary values) match none of these and pass through intact.
2237
2246
  */
2238
2247
  const SETTINGS_ERROR_LEAK_PATTERNS = [
2239
2248
  // Tracebacks and Python/Node interpreter failure lines.
@@ -2241,7 +2250,39 @@ const SETTINGS_ERROR_LEAK_PATTERNS = [
2241
2250
  /File\s+["'][^"']+["']\s*,\s*line\s+\d+/i,
2242
2251
  /\b(?:ModuleNotFoundError|ImportError|SyntaxError|NameError|OSError|Errno \d+)\b/,
2243
2252
  // Absolute POSIX path of any root: a bounded leading slash + >=2 segments.
2244
- /(?:^|[\s"'(=:,])\/(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+/,
2253
+ // Phase-08 R1 (W9) REWORK-2 L2: prefix class gains `[`, `{`, `-` so
2254
+ // bracket/brace/dash-delimited absolutes (`[/etc]`, `{/etc}`, `-/etc`,
2255
+ // `x-/lib64,y`) redact. Canonical values contain no `/`-root tokens
2256
+ // (proven by the canonical-value corpus passing verbatim), so `-`-prefixed
2257
+ // path tokens are disclosures, not user echoes.
2258
+ /(?:^|[\s"'(=:,\[{-])\/(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+/,
2259
+ // Phase-08 R1 (W9) REWORK-1: single-segment system-root absolutes
2260
+ // (`/etc`, `/tmp`, plus `/lib`, `/lib64`, `/boot`, `/snap`, macOS `/System`,
2261
+ // `/Library`, `/Volumes`, `/Applications`).
2262
+ // The >=2-segment arm above lets these through, but a bare system root in
2263
+ // a failure item is a local-filesystem disclosure (interpreter stderr
2264
+ // echoes absolute paths of any depth). The root allowlist keeps ordinary
2265
+ // user echoes intact: single-segment non-system values (`/v1`, `/nope`)
2266
+ // still pass through, and sanitization stays per-item (R9).
2267
+ // RESIDUAL (documented): truly exotic single-segment roots outside the
2268
+ // mainstream Linux + macOS set (e.g. `/net`, `/nfs`, future mount points)
2269
+ // still pass through by design to avoid over-redacting user echoes.
2270
+ // TRIGGER (consumed for this class): qa-b repro `["error at /lib nope"]`
2271
+ // passthrough + inspection-named `/lib64`, `/boot`, `/snap`, `/System`,
2272
+ // `/Library`, `/Volumes`, `/Applications` are now covered. Revisit only
2273
+ // with a NEW real leak repro naming the still-uncovered root.
2274
+ // Phase-08 R1 (W9) REWORK-2 L1: suffix lookahead gains `.!?]}` so
2275
+ // bare-root + sentence punctuation (`/lib.`, `/etc.`, `/lib!`, `/tmp?`,
2276
+ // `/lib]`, `/lib}`) redacts. The >=2-segment arm is untouched (its trailing
2277
+ // `[A-Za-z0-9._-]+` already consumes `/etc/foo.`). L2: prefix class gains
2278
+ // `[`, `{`, `-` (same `-` safety proof as above) so `[/etc]`, `[/lib]`,
2279
+ // `[/lib64]`, `{/etc}`, `-/etc`, `x-/lib64,y` redact.
2280
+ /(?:^|[\s"'(=:,\[{-])\/(?:etc|var|tmp|srv|app|home|root|usr|opt|bin|sbin|proc|sys|dev|run|mnt|media|private|Users|workspace|lib64|lib|boot|snap|System|Library|Volumes|Applications)(?=\/|$|[\s"'<>|,;:().!?\]}])/,
2281
+ // Phase-08 R1 (W9): `file://` URIs always disclose local filesystem
2282
+ // layout, and no canonical config value is a file:// URI (`searxng_url`
2283
+ // requires an http(s) scheme) — so a file:// URI in a failure item is
2284
+ // never a legitimate user echo. Fail closed.
2285
+ /file:\/\/[^\s"'<>|]+/i,
2245
2286
  // Windows drive-letter absolute path (`C:\Users\…`) or UNC share (`\\srv\share`).
2246
2287
  /\b[A-Za-z]:[\\/](?:[^\\/\s"'<>|]+[\\/])*[^\\/\s"'<>|]+/,
2247
2288
  /\\\\[^\\/\s"'<>|]+[\\/][^\\/\s"'<>|]+/,
@@ -504,9 +504,98 @@ const WIZARD_KEEP_VALUE = '__iumbtems_keep__';
504
504
 
505
505
  function wizardToast(host, title, message) {
506
506
  try {
507
- host?.ui?.toast?.show?.({ title, message, variant: 'info', duration: 6000 });
507
+ const toast = host?.ui?.toast;
508
+ const show = toast?.show;
509
+ if (typeof show !== 'function') return false;
510
+ show.call(toast, { title, message, variant: 'info', duration: 6000 });
511
+ return true;
508
512
  } catch {
509
- /* toast is best-effort */
513
+ return false;
514
+ }
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // Phase 07 (H8): keyed advisory dedup window for the repeating R4 advisories.
519
+ //
520
+ // Editing an env-shadowed or deprecated row fires its advisory on EVERY edit,
521
+ // so a multi-edit wizard session spams one toast per row visit. `warnOnce`
522
+ // collapses repeats of the SAME advisory key inside a TTL window: the first
523
+ // shows, repeats within the window are suppressed (counted), expiry re-shows
524
+ // (annotated with the suppressed count), and distinct keys stay independent.
525
+ //
526
+ // SAFETY: only the two repeating R4 advisories route here. Degraded-path,
527
+ // malformed-config, validation-error, stale, and save-failure toasts call
528
+ // `wizardToast` directly and are NEVER deduped (missed-action safety).
529
+ // Durations are preserved: wizard advisories stay 6000ms via `wizardToast`
530
+ // (the 4000ms keymap usage toast is untouched).
531
+ // ---------------------------------------------------------------------------
532
+
533
+ /** Dedup window for repeating R4 advisories, in ms. Exported for tests. */
534
+ export const ADVISORY_DEDUP_MS = 30000;
535
+
536
+ /** key -> { ts, suppressed }: last show time + repeats collapsed since. */
537
+ const advisorySeen = new Map();
538
+
539
+ /**
540
+ * Show a keyed advisory toast at most once per `ttlMs` window.
541
+ * Returns true when a toast was actually delivered, false when collapsed
542
+ * or when delivery failed (failed deliveries never arm the window).
543
+ * `opts.now` injects the clock (tests); `opts.ttlMs` overrides the window.
544
+ * Session scope: each `openSettingsWizard` call resets the window (fresh
545
+ * wizard session always warns at least once); repeats within one session
546
+ * still collapse. Never throws.
547
+ */
548
+ export function warnOnce(host, key, title, message, opts = {}) {
549
+ try {
550
+ const ttl =
551
+ opts && opts.ttlMs !== undefined && opts.ttlMs !== null
552
+ ? Number(opts.ttlMs)
553
+ : ADVISORY_DEDUP_MS;
554
+ const now =
555
+ opts && opts.now !== undefined && opts.now !== null
556
+ ? Number(opts.now)
557
+ : Date.now();
558
+ const entry = advisorySeen.get(key);
559
+ if (entry && Number.isFinite(ttl) && ttl > 0) {
560
+ const dt = now - entry.ts;
561
+ if (Number.isFinite(dt) && dt >= 0 && dt < ttl) {
562
+ entry.suppressed += 1;
563
+ return false;
564
+ }
565
+ // dt < 0 (clock moved backwards) or dt >= ttl (expiry) fall through
566
+ // to reshow — a backwards clock must never suppress indefinitely.
567
+ }
568
+ let text = message;
569
+ if (entry && entry.suppressed > 0) {
570
+ text += ` (repeated ${entry.suppressed + 1}× since last notice)`;
571
+ }
572
+ // Arm the window ONLY on actual delivery: a failed toast (missing/broken
573
+ // host) leaves state untouched so the next real warning still delivers.
574
+ const delivered = wizardToast(host, title, text);
575
+ if (!delivered) return false;
576
+ advisorySeen.set(key, { ts: now, suppressed: 0 });
577
+ return true;
578
+ } catch {
579
+ return false;
580
+ }
581
+ }
582
+
583
+ /** Test hook: forget all dedup state. Never throws. */
584
+ export function _resetAdvisoryDedup() {
585
+ try {
586
+ advisorySeen.clear();
587
+ } catch {
588
+ /* never throw */
589
+ }
590
+ }
591
+
592
+ /** Test hook: suppressed-repeat count pending for `key`. Never throws. */
593
+ export function _advisorySuppressed(key) {
594
+ try {
595
+ const entry = advisorySeen.get(key);
596
+ return entry ? entry.suppressed : 0;
597
+ } catch {
598
+ return 0;
510
599
  }
511
600
  }
512
601
 
@@ -715,23 +804,29 @@ async function editRow(dialog, host, draft, row) {
715
804
  try {
716
805
  const sub = row.schema || {};
717
806
  // R4 advisories (never block the edit; the write still proceeds):
718
- // an env-shadowed value has no runtime effect until the override is
719
- // unset, and a deprecated key is display-only.
807
+ // an env-shadowed value is not effective while the override is set, and
808
+ // a deprecated key is ignored at runtime. Both route through the keyed
809
+ // dedup window (per-key repeats collapse); every other toast stays
810
+ // un-deduped so no error or degraded-path notice is ever swallowed.
720
811
  if (row.source === 'env-override') {
721
- wizardToast(
812
+ warnOnce(
722
813
  host,
814
+ `env:${row.key}`,
723
815
  'Environment override',
724
- `The environment shadows '${row.key}' — editing the file value has ` +
725
- 'no runtime effect until the override is unset. The stored value is ' +
726
- 'still saved on confirm.'
816
+ `The environment shadows '${row.key}' — the file value is not ` +
817
+ 'effective while the override is set and has no runtime effect ' +
818
+ 'until the override is unset. The edited value is still stored ' +
819
+ 'on save (cancel writes nothing).'
727
820
  );
728
821
  }
729
822
  if (row.deprecated) {
730
- wizardToast(
823
+ warnOnce(
731
824
  host,
825
+ `deprecated:${row.key}`,
732
826
  'Deprecated field',
733
- `'${row.key}' is deprecated and display-only — it never selects the ` +
734
- 'workspace. The stored value is still saved on confirm.'
827
+ `'${row.key}' is deprecated and ignored — it has no runtime ` +
828
+ 'effect. The edited value is still stored on save for reference, ' +
829
+ 'but changes nothing (cancel writes nothing).'
735
830
  );
736
831
  }
737
832
  const types = Array.isArray(sub.type) ? sub.type : [sub.type];
@@ -999,6 +1094,15 @@ async function editAgentsRow(dialog, host, draft, row) {
999
1094
  */
1000
1095
  export async function openSettingsWizard(host, root, opts = {}) {
1001
1096
  try {
1097
+ // R1 session scope: a fresh wizard session always warns at least once.
1098
+ // The keyed R4 window collapses repeats WITHIN one session only; reset
1099
+ // the boundary here so a prior session (same key, <30s ago) never leaks
1100
+ // into the new session. Never throws.
1101
+ try {
1102
+ advisorySeen.clear();
1103
+ } catch {
1104
+ /* dedup reset is best-effort */
1105
+ }
1002
1106
  const dialog = dialogOf(host);
1003
1107
  const baseDir = researchDirOf(host, root);
1004
1108
  const env = (opts && opts.env) || process.env;
@@ -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: