@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/cli.js +3 -2
- package/extensions/pi/index.js +3 -2
- package/package.json +5 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/factory/skills/factory/SKILL.md +1 -1
- package/plugins/opencode/analysis-bridge.js +566 -0
- package/plugins/opencode/config-io.js +598 -31
- package/plugins/opencode/dep-health.js +1039 -0
- package/plugins/opencode/hook-bus.js +434 -0
- package/plugins/opencode/index.js +252 -19
- package/plugins/opencode/tui.js +230 -17
- package/plugins/opencode/weight-baseline.json +16 -0
- package/plugins/opencode/weight-signals.js +769 -0
- package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +249 -81
- package/runner/refinement.py +5 -0
- package/runner/schema_validate.py +63 -1
- package/runner/schemas.py +9 -4
- package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/config_validation_cases.json +60 -0
- package/runner/tests/test_analysis_bridge.py +356 -0
- package/runner/tests/test_config_schema.py +119 -15
- package/runner/tests/test_dep_health.py +484 -0
- package/runner/tests/test_hardening_contract.py +391 -0
- package/runner/tests/test_hook_bus.py +802 -0
- package/runner/tests/test_opencode_ux.py +232 -0
- package/runner/tests/test_opencode_v2_registrars.py +548 -16
- package/runner/tests/test_phase08_waiver_reopens.py +539 -0
- package/runner/tests/test_weight_signals.py +361 -0
- package/schemas/config.schema.json +4 -3
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/factory/SKILL.md +1 -1
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +33 -8
- package/skills/swarm_config/SKILL.md +6 -1
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- 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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runner/mcp_server.py
CHANGED
|
@@ -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.
|
|
109
|
-
"
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
271
|
-
#
|
|
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": "
|
|
275
|
-
"
|
|
276
|
-
"
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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":
|
|
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
|
-
|
|
610
|
-
if
|
|
611
|
-
|
|
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)
|
package/runner/refinement.py
CHANGED
|
@@ -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:
|