@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
@@ -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:
@@ -96,7 +96,11 @@ def config_tool_input_schema() -> Dict[str, Any]:
96
96
  "type": "boolean",
97
97
  "description": (
98
98
  "Inspect only; equivalent to calling with no update keys "
99
- "(reads are always safe)."
99
+ "(reads are always safe). When true, the call is read-only "
100
+ "even if update keys are present: they are listed under "
101
+ "`ignored_updates` and nothing is written (the expected_hash "
102
+ "guard, including the dual-spelling conflict check, is not "
103
+ "evaluated)."
100
104
  ),
101
105
  },
102
106
  "expected_hash": {
@@ -105,8 +109,10 @@ def config_tool_input_schema() -> Dict[str, Any]:
105
109
  "Optimistic-concurrency guard: SHA-256 (or a unique prefix of at "
106
110
  "least 8 hex chars) of the config file you read. A mismatch "
107
111
  "returns a structured stale error with a fresh snapshot and "
108
- "writes nothing. Alias: `expectedHash`; if both spellings are "
109
- "present they must be equal or the call is rejected."
112
+ "writes nothing. Empty/whitespace-only spellings count as absent. "
113
+ "Alias: `expectedHash`; if both spellings are "
114
+ "present they must be equal after normalization (strip + "
115
+ "lowercase) or the call is rejected."
110
116
  ),
111
117
  },
112
118
  "expectedHash": {
@@ -145,6 +151,135 @@ def _allows_null(schema: Dict[str, Any]) -> bool:
145
151
  return types == "null"
146
152
 
147
153
 
154
+ def _normalize_hash_spelling(value: Any) -> str:
155
+ """Strip + lowercase a hash spelling for conflict comparison (H2).
156
+
157
+ Mirrors `normalize_expected_hash` in `skills/swarm_config/configure.py`
158
+ WITHOUT raising: malformed spellings still compare (so genuinely different
159
+ malformed guards conflict), and the single `save_config` writer later
160
+ validates the winner (malformed -> CONFIG_INVALID_EXPECTED_HASH).
161
+ Empty/whitespace-only spellings normalize to "" and are treated as absent
162
+ by the caller (R3: "" never conflicts).
163
+ """
164
+ return str(value).strip().lower()
165
+
166
+
167
+ def _hash_spelling_present(value: Any) -> bool:
168
+ """True when an expected-hash spelling counts as provided (R3).
169
+
170
+ `None` and empty/whitespace-only strings mean "no guard" (mirroring
171
+ `normalize_expected_hash`); everything else is a guard spelling even if
172
+ malformed (malformed -> CONFIG_INVALID_EXPECTED_HASH on the write leg).
173
+ """
174
+ if value is None:
175
+ return False
176
+ return str(value).strip() != ""
177
+
178
+
179
+ def _find_nested_null_violations(updates: Dict[str, Any]) -> List[str]:
180
+ """Dotted paths in `updates` carrying None where the schema bans null (H4).
181
+
182
+ The top-level loop in `_handle_config` already catches a direct
183
+ `{"mode": None}`; this extends the same `NULL_FOR_NON_NULLABLE_KEY` code
184
+ to nested nulls such as `{"verify": {"min_fuzzy_confidence": None}}`,
185
+ which previously surfaced as the generic `CONFIG_VALIDATION_FAILED`.
186
+ Contract: `NULL_FOR_NON_NULLABLE_KEY` wins for ANY caller-supplied null
187
+ at any depth; `CONFIG_VALIDATION_FAILED` covers every other schema
188
+ violation (including nulls already persisted on disk by a hand edit,
189
+ which the caller did not supply in this call).
190
+ """
191
+ from runner.schemas import CONFIG
192
+
193
+ found: List[str] = []
194
+
195
+ def walk(value: Any, subschema: Any, path: str) -> None:
196
+ if value is None:
197
+ if isinstance(subschema, dict) and not _allows_null(subschema):
198
+ found.append(path)
199
+ return
200
+ if isinstance(value, dict) and isinstance(subschema, dict):
201
+ props = subschema.get("properties") or {}
202
+ additional = subschema.get("additionalProperties")
203
+ for key, child in value.items():
204
+ if key in props:
205
+ walk(child, props[key], f"{path}.{key}" if path else str(key))
206
+ elif isinstance(additional, dict):
207
+ # e.g. `mcp_servers` values must be boolean; a null there
208
+ # is a null-violation too, with the full dotted path.
209
+ walk(
210
+ child,
211
+ additional,
212
+ f"{path}.{key}" if path else str(key),
213
+ )
214
+ # Unknown keys with no `additionalProperties` schema carry no
215
+ # nullability info; generic validation decides them (unknown
216
+ # object keys are preserved verbatim).
217
+ return
218
+ if isinstance(value, list) and isinstance(subschema, dict):
219
+ items = subschema.get("items")
220
+ if isinstance(items, dict):
221
+ for idx, child in enumerate(value):
222
+ walk(child, items, f"{path}[{idx}]")
223
+
224
+ props = CONFIG.get("properties") or {}
225
+ for key, value in updates.items():
226
+ if value is None or not isinstance(value, (dict, list)):
227
+ continue # top-level nulls are handled by the caller loop
228
+ canon = CONFIG_KEY_ALIASES.get(key, key)
229
+ if canon in props:
230
+ walk(value, props[canon], str(canon))
231
+ return sorted(found)
232
+
233
+
234
+ def _config_updated_payload(
235
+ cfg_file: Path, candidate: Dict[str, Any], default_config: Dict[str, Any]
236
+ ) -> Dict[str, Any]:
237
+ """Success payload for the config write leg (H6).
238
+
239
+ `written_to` is ALWAYS an absolute path (via realpath, so symlinked
240
+ workspaces resolve to the true location). Both updated legs — the normal
241
+ single-read leg and the defensive branch where the just-written bytes
242
+ cannot be re-read — share this helper so the shape cannot diverge.
243
+ """
244
+ import hashlib
245
+
246
+ from skills.swarm_config.configure import _read_bounded_bytes, merge_config
247
+
248
+ abs_path = str(Path(os.path.realpath(str(cfg_file))))
249
+ try:
250
+ # Phase-08 R2 (W17): bounded re-read — the just-written file is
251
+ # regular by construction, but a single read primitive keeps the
252
+ # FIFO-hardening total (a concurrent swap can only fail into the
253
+ # defensive branch below, never hang the writer).
254
+ raw_bytes = _read_bounded_bytes(Path(cfg_file))
255
+ except OSError:
256
+ raw_bytes = None
257
+ if raw_bytes is None:
258
+ # A successful save always leaves bytes; keep the payload shape without
259
+ # a second read in this (unreachable) defensive branch.
260
+ return {
261
+ "status": "updated",
262
+ "config": candidate,
263
+ "written_to": abs_path,
264
+ "hash": None,
265
+ }
266
+ try:
267
+ parsed = json.loads(raw_bytes.decode("utf-8"))
268
+ except (ValueError, UnicodeDecodeError):
269
+ parsed = None
270
+ effective = (
271
+ merge_config(default_config, parsed)
272
+ if isinstance(parsed, dict)
273
+ else merge_config(default_config, {})
274
+ )
275
+ return {
276
+ "status": "updated",
277
+ "config": effective,
278
+ "written_to": abs_path,
279
+ "hash": hashlib.sha256(raw_bytes).hexdigest(),
280
+ }
281
+
282
+
148
283
  def _legacy_pin_migration_pending(base_dir: str) -> bool:
149
284
  """Would the legacy-pin migration apply on the next write? (read-only)
150
285
 
@@ -156,15 +291,20 @@ def _legacy_pin_migration_pending(base_dir: str) -> bool:
156
291
  """
157
292
  try:
158
293
  from skills.swarm_config.configure import (
294
+ _read_bounded_bytes,
159
295
  load_config,
160
296
  migrate_legacy_agent_backends,
161
297
  validate_config,
162
298
  )
163
299
 
164
300
  cfg_file = Path(base_dir) / "config.json"
165
- if not cfg_file.is_file():
301
+ # Phase-08 R2 (W17): bounded pre-read — a FIFO here hung the show leg
302
+ # on a plain `read_bytes`. Unreadable/non-regular yields "no pending
303
+ # migration" (fail-open read, matching `load_config` defaults).
304
+ raw_bytes = _read_bounded_bytes(cfg_file)
305
+ if raw_bytes is None:
166
306
  return False
167
- raw = json.loads(cfg_file.read_bytes().decode("utf-8"))
307
+ raw = json.loads(raw_bytes.decode("utf-8"))
168
308
  if not isinstance(raw, dict):
169
309
  return False
170
310
  if not migrate_legacy_agent_backends(raw):
@@ -182,9 +322,16 @@ def _handle_config(args: Dict[str, Any]) -> str:
182
322
  is written. Writes go through the single canonical writer (`save_config`:
183
323
  locked, validated, atomic, unknown-key-preserving) with an optional
184
324
  `expected_hash` guard (both `expected_hash` and `expectedHash` spellings).
185
- """
186
- import hashlib
187
325
 
326
+ Phase-05 H2 decisions: the two hash spellings compare NORMALIZED
327
+ (strip + lowercase, mirroring `normalize_expected_hash`), so
328
+ `"ABCDEF12"` vs `"abcdef12"` is one guard, not a conflict; empty or
329
+ whitespace-only spellings count as absent on both sides (R3), so `""`
330
+ never conflicts; and `show` forces the read-only leg even when update
331
+ keys are present (they are reported under `ignored_updates` and nothing
332
+ is written, without evaluating the guard or the dual-spelling conflict
333
+ check — R1 ordering).
334
+ """
188
335
  from skills.swarm_config.configure import (
189
336
  DEFAULT_CONFIG,
190
337
  ConfigError,
@@ -198,24 +345,10 @@ def _handle_config(args: Dict[str, Any]) -> str:
198
345
  base_dir = str(_resolve_base_dir(args))
199
346
  cfg = load_config(base_dir)
200
347
 
201
- # R4: both spellings must agree; never silently prefer one.
202
348
  snake = args.get("expected_hash")
203
349
  camel = args.get("expectedHash")
204
- if snake is not None and camel is not None and str(snake) != str(camel):
205
- return _tool_text(
206
- {
207
- "status": "error",
208
- "code": "CONFLICTING_EXPECTED_HASH",
209
- "written": False,
210
- "errors": [
211
- "expected_hash and expectedHash were both provided with "
212
- "different values; provide one spelling (or two equal values)."
213
- ],
214
- "expected_hash": str(snake),
215
- "expectedHash": str(camel),
216
- }
217
- )
218
- expected = snake if snake is not None else camel
350
+ snake_present = _hash_spelling_present(snake)
351
+ camel_present = _hash_spelling_present(camel)
219
352
 
220
353
  accepted = _canonical_config_keys()
221
354
  nullable = [key for key in accepted if _allows_null(CONFIG["properties"][key])]
@@ -249,6 +382,9 @@ def _handle_config(args: Dict[str, Any]) -> str:
249
382
  "accepted_keys": accepted,
250
383
  }
251
384
  )
385
+ # H4: nested caller-supplied nulls share the top-level code, with dotted
386
+ # paths (e.g. `verify.min_fuzzy_confidence`).
387
+ null_for_non_nullable.extend(_find_nested_null_violations(updates))
252
388
  if null_for_non_nullable:
253
389
  return _tool_text(
254
390
  {
@@ -257,7 +393,7 @@ def _handle_config(args: Dict[str, Any]) -> str:
257
393
  "written": False,
258
394
  "errors": [
259
395
  f"null is not accepted for non-nullable key(s): "
260
- f"{', '.join(sorted(null_for_non_nullable))}; omit the key to "
396
+ f"{', '.join(sorted(set(null_for_non_nullable)))}; omit the key to "
261
397
  "leave it unchanged, or pass a valid value"
262
398
  ],
263
399
  "nullable_keys": nullable,
@@ -265,17 +401,52 @@ def _handle_config(args: Dict[str, Any]) -> str:
265
401
  }
266
402
  )
267
403
 
268
- if not updates:
269
- # R11: the show leg is strictly read-only — a settings panel reads
270
- # through it, so it must never persist the legacy-pin migration. Report
271
- # whether a migration is pending; only the write leg below heals.
404
+ if args.get("show") or not updates:
405
+ # R11/H2/R1: the show leg is strictly read-only — a settings panel reads
406
+ # through it, so it must never persist the legacy-pin migration nor
407
+ # evaluate the expected_hash guard (including the dual-spelling
408
+ # conflict check, which lives BELOW this return). Report whether a
409
+ # migration is pending; only the write leg below heals. When `show`
410
+ # overrides present update keys, name them so the caller knows nothing
411
+ # was written.
412
+ payload: Dict[str, Any] = {
413
+ "status": "current",
414
+ "config": cfg,
415
+ "migrated": _legacy_pin_migration_pending(base_dir),
416
+ }
417
+ if args.get("show") and updates:
418
+ payload["ignored_updates"] = sorted(updates)
419
+ return _tool_text(payload)
420
+
421
+ # R1 ordering: the dual-spelling conflict check lives BELOW the show
422
+ # early-return so the show leg is fully read-only. R4/H2: both spellings
423
+ # must agree AFTER normalization; never silently prefer one.
424
+ # Case/whitespace-only differences are one guard; empty/whitespace-only
425
+ # spellings count as absent (R3).
426
+ if (
427
+ snake_present
428
+ and camel_present
429
+ and _normalize_hash_spelling(snake) != _normalize_hash_spelling(camel)
430
+ ):
272
431
  return _tool_text(
273
432
  {
274
- "status": "current",
275
- "config": cfg,
276
- "migrated": _legacy_pin_migration_pending(base_dir),
433
+ "status": "error",
434
+ "code": "CONFLICTING_EXPECTED_HASH",
435
+ "written": False,
436
+ "errors": [
437
+ "expected_hash and expectedHash were both provided with "
438
+ "different values; provide one spelling (or two equal values)."
439
+ ],
440
+ "expected_hash": str(snake),
441
+ "expectedHash": str(camel),
277
442
  }
278
443
  )
444
+ if snake_present:
445
+ expected: Any = snake
446
+ elif camel_present:
447
+ expected = camel
448
+ else:
449
+ expected = None
279
450
 
280
451
  candidate = merge_config(cfg, updates)
281
452
  problems = validate_config(candidate)
@@ -315,39 +486,9 @@ def _handle_config(args: Dict[str, Any]) -> str:
315
486
  # `save_config` publishes a fully defaults-merged, validated object
316
487
  # atomically, so the bytes on disk are already the effective config; the
317
488
  # earlier `load_config()` + `config_hash()` were two independent reads.
489
+ # H6: the shared helper guarantees an absolute `written_to` on both legs.
318
490
  cfg_file = Path(base_dir) / "config.json"
319
- try:
320
- raw_bytes = cfg_file.read_bytes()
321
- except OSError:
322
- raw_bytes = None
323
- if raw_bytes is None:
324
- # A successful save always leaves bytes; keep the payload shape without
325
- # a second read in this (unreachable) defensive branch.
326
- return _tool_text(
327
- {
328
- "status": "updated",
329
- "config": candidate,
330
- "written_to": str(cfg_file),
331
- "hash": None,
332
- }
333
- )
334
- try:
335
- parsed = json.loads(raw_bytes.decode("utf-8"))
336
- except (ValueError, UnicodeDecodeError):
337
- parsed = None
338
- effective = (
339
- merge_config(DEFAULT_CONFIG, parsed)
340
- if isinstance(parsed, dict)
341
- else merge_config(DEFAULT_CONFIG, {})
342
- )
343
- return _tool_text(
344
- {
345
- "status": "updated",
346
- "config": effective,
347
- "written_to": str(cfg_file),
348
- "hash": hashlib.sha256(raw_bytes).hexdigest(),
349
- }
350
- )
491
+ return _tool_text(_config_updated_payload(cfg_file, candidate, DEFAULT_CONFIG))
351
492
 
352
493
 
353
494
  def _run_swarm_mode(mode: Optional[str], args: Dict[str, Any]) -> str:
@@ -449,12 +590,18 @@ def _handle_darkharvest(args: Dict[str, Any]) -> str:
449
590
 
450
591
 
451
592
  def _handle_factory(args: Dict[str, Any]) -> str:
452
- """Drive factory run state (init / phase-add / qa-record / expansion / stop).
593
+ """Drive factory run state (init / phase-add / qa-record / expansion / stop / gate).
453
594
 
454
595
  Wraps skills/factory/scripts/factory.py so agents never need a filesystem
455
596
  path to the helper: the npm-installed toolchain lives outside the project,
456
597
  and relative `skills/...` paths broke in consuming projects (observed
457
598
  live: the manager agent ran `find / -name factory.py`).
599
+
600
+ Phase-08 R4 (W6): the `gate` command forwards `--run/--phase/--action`
601
+ (+ optional `--reason`) to the helper's `gate` subcommand. Before this
602
+ fix the handler accepted `gate` but forwarded no gate args, so the helper
603
+ exited 2 (missing required `--run/--phase/--action`) and gate was
604
+ unreachable through the tool surface.
458
605
  """
459
606
  import subprocess
460
607
 
@@ -508,6 +655,17 @@ def _handle_factory(args: Dict[str, Any]) -> str:
508
655
  cmd += ["--max-loops", str(int(args["max_loops"]))]
509
656
  elif command == "stop":
510
657
  cmd += ["--run", str(args.get("run") or "")]
658
+ elif command == "gate":
659
+ cmd += [
660
+ "--run",
661
+ str(args.get("run") or ""),
662
+ "--phase",
663
+ str(args.get("phase") or ""),
664
+ "--action",
665
+ str(args.get("action") or ""),
666
+ ]
667
+ if args.get("reason"):
668
+ cmd += ["--reason", str(args["reason"])]
511
669
 
512
670
  proc = subprocess.run(
513
671
  cmd,
@@ -529,24 +687,28 @@ def _handle_factory(args: Dict[str, Any]) -> str:
529
687
  return _tool_text(payload)
530
688
 
531
689
 
532
- def _verify_min_fuzzy_confidence(cfg: Any) -> float:
533
- """Configured `verify.min_fuzzy_confidence`, defaulting to 0.88.
690
+ def _verify_min_fuzzy_confidence(cfg: Any) -> Any:
691
+ """Configured `verify.min_fuzzy_confidence`, defaulting to 0.88 (H5).
534
692
 
535
- The value is clamped into [0, 1]; a malformed hand-edited file falls back to
536
- the strict default instead of crashing the tool.
693
+ SINGLE-ENFORCEMENT-POINT CONTRACT: this returns the configured value
694
+ UNTOUCHED (no `float()` parse, no clamp) and `SourceHasher.verify_quote`
695
+ enforces it via `coerce_min_fuzzy_confidence` at call time. The persisted
696
+ schema stays strict (`type: number`); coercion exists only for
697
+ hand-edited files and direct library callers. The response echoes the
698
+ EFFECTIVE threshold (`coerce_min_fuzzy_confidence(configured)`), so the
699
+ reported value is always the one that governed the decision.
537
700
  """
538
701
  verify = cfg.get("verify") if isinstance(cfg, dict) else None
539
702
  raw = verify.get("min_fuzzy_confidence") if isinstance(verify, dict) else None
540
- try:
541
- value = float(raw)
542
- except (TypeError, ValueError):
543
- return 0.88
544
- return max(0.0, min(1.0, value))
703
+ return 0.88 if raw is None else raw
545
704
 
546
705
 
547
706
  def _handle_verify_quote(args: Dict[str, Any]) -> str:
548
707
  """Verify a verbatim quote against the content-addressed source cache."""
549
- from skills.research_cache.hasher import SourceHasher
708
+ from skills.research_cache.hasher import (
709
+ SourceHasher,
710
+ coerce_min_fuzzy_confidence,
711
+ )
550
712
  from skills.swarm_config.configure import load_config
551
713
 
552
714
  content_hash = (
@@ -557,10 +719,10 @@ def _handle_verify_quote(args: Dict[str, Any]) -> str:
557
719
  raise ValueError("hash and quote are required")
558
720
 
559
721
  base_dir = _resolve_base_dir(args)
560
- threshold = _verify_min_fuzzy_confidence(load_config(str(base_dir)))
722
+ configured = _verify_min_fuzzy_confidence(load_config(str(base_dir)))
561
723
  hasher = SourceHasher(base_dir)
562
724
  is_verified, confidence, message = hasher.verify_quote(
563
- content_hash, quote, min_fuzzy_confidence=threshold
725
+ content_hash, quote, min_fuzzy_confidence=configured
564
726
  )
565
727
  return _tool_text(
566
728
  {
@@ -568,7 +730,7 @@ def _handle_verify_quote(args: Dict[str, Any]) -> str:
568
730
  "confidence": float(confidence),
569
731
  "message": message,
570
732
  "content_hash": content_hash,
571
- "min_fuzzy_confidence": threshold,
733
+ "min_fuzzy_confidence": coerce_min_fuzzy_confidence(configured),
572
734
  }
573
735
  )
574
736
 
@@ -602,13 +764,19 @@ def _handle_check_staleness(args: Dict[str, Any]) -> str:
602
764
 
603
765
 
604
766
  def _handle_set_domain_pack(args: Dict[str, Any]) -> str:
605
- """Activate a Regulated Domain Pack (epistemic constitution) in config."""
767
+ """Activate a Regulated Domain Pack (epistemic constitution) in config.
768
+
769
+ H3: surrounding whitespace is stripped before resolving — a blank or
770
+ whitespace-only id is rejected (the canonical schema additionally rejects
771
+ blank/padded ids at validation, so a padded id can never persist).
772
+ """
606
773
  from runner.refinement import load_domain_pack
607
774
  from skills.swarm_config.configure import load_config, save_config
608
775
 
609
- pack = args.get("pack") or args.get("domain_pack") or args.get("pack_id")
610
- if not pack:
611
- raise ValueError("pack is required")
776
+ raw_pack = args.get("pack") or args.get("domain_pack") or args.get("pack_id")
777
+ pack = raw_pack.strip() if isinstance(raw_pack, str) else raw_pack
778
+ if not pack or (isinstance(pack, str) and not pack.strip()):
779
+ raise ValueError("pack is required (blank/whitespace-only ids are rejected)")
612
780
  constitution = load_domain_pack(pack) # raises if unknown — fail loudly
613
781
  base_dir = str(_resolve_base_dir(args))
614
782
  cfg = load_config(base_dir)
@@ -87,10 +87,15 @@ def load_domain_pack(pack: str) -> Constitution:
87
87
 
88
88
  Unknown/unloadable packs raise FileNotFoundError — silent fallback to
89
89
  legacy would hide a regulatory misconfiguration.
90
+
91
+ Surrounding whitespace is stripped before resolving (H3), so a padded id
92
+ from a UI affordance resolves like the clean id; blank ids still raise.
90
93
  """
94
+
91
95
  import json
92
96
  from pathlib import Path
93
97
 
98
+ pack = pack.strip() if isinstance(pack, str) else pack
94
99
  candidates = []
95
100
  p = Path(pack)
96
101
  if p.suffix == ".json" or "/" in pack: