@heretek-ai/epistemic-swarm 0.7.0 → 0.7.2

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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +1 -0
  3. package/extensions/pi/index.js +7 -1
  4. package/package.json +1 -1
  5. package/plugins/darkharvest/skills/darkharvest/scripts/harvest.py +76 -39
  6. package/plugins/factory/skills/factory/scripts/factory.py +1 -1
  7. package/plugins/opencode/index.js +41 -1
  8. package/plugins/opencode/tui.js +66 -38
  9. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  23. package/runner/claim_store.py +15 -4
  24. package/runner/living_dossiers.py +74 -56
  25. package/runner/mcp_server.py +37 -2
  26. package/runner/pcrb.py +53 -29
  27. package/runner/refinement.py +70 -27
  28. package/runner/research_swarm.py +336 -127
  29. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  46. package/runner/tests/test_backends.py +280 -0
  47. package/runner/tests/test_sweep_regressions.py +71 -38
  48. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  51. package/scripts/build_adapters.py +27 -18
  52. package/skills/darkharvest/scripts/harvest.py +76 -39
  53. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  54. package/skills/epistemic_search/scripts/search.py +29 -19
  55. package/skills/epistemic_search/scripts/webcache.py +29 -17
  56. package/skills/factory/scripts/factory.py +1 -1
  57. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  58. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  59. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  60. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  61. package/skills/swarm_config/configure.py +70 -37
@@ -59,11 +59,19 @@ TEXT_EXTS = {".md", ".json", ".toml", ".yaml", ".yml", ".txt", ".py", ".js", ".t
59
59
  DEFAULT_MAX_FILES = 120
60
60
  DEFAULT_MAX_BYTES = 400_000
61
61
  DEFAULT_TIMEOUT = 120
62
+ DEFAULT_BASE_DIR = ".research"
63
+ MAX_SCAN_DEPTH = 3
64
+ MAX_SINGLE_FILE_BYTES = 100_000
65
+ SNIPPET_CHARS = 4000
66
+
67
+ SPDX_APACHE2 = "Apache-2.0"
68
+ PERMISSIVE_SPDX = frozenset({"MIT", SPDX_APACHE2, "BSD-3-Clause", "ISC"})
69
+ COPYLEFT_SPDX = frozenset({"GPL", "AGPL"})
62
70
 
63
71
  SPDX_MAP = {
64
72
  "mit": "MIT",
65
- "apache-2.0": "Apache-2.0",
66
- "apache 2.0": "Apache-2.0",
73
+ "apache-2.0": SPDX_APACHE2,
74
+ "apache 2.0": SPDX_APACHE2,
67
75
  "bsd-3-clause": "BSD-3-Clause",
68
76
  "bsd 3-clause": "BSD-3-Clause",
69
77
  "isc": "ISC",
@@ -97,43 +105,74 @@ def detect_license(text):
97
105
 
98
106
 
99
107
  def scan_tree(root, max_files=DEFAULT_MAX_FILES, max_bytes=DEFAULT_MAX_BYTES):
100
- entries = []
101
- total_bytes = 0
102
- manifests = {}
103
- license_text = ""
104
- readme_head = ""
108
+ root = Path(root)
109
+ state = _ScanState()
105
110
  for dirpath, dirnames, filenames in os.walk(root):
106
111
  dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
107
112
  rel = Path(dirpath).relative_to(root)
108
- depth = len(rel.parts) if str(rel) != "." else 0
109
- if depth > 3:
113
+ if (len(rel.parts) if str(rel) != "." else 0) > MAX_SCAN_DEPTH:
110
114
  dirnames[:] = []
111
115
  continue
116
+ prefix = "" if str(rel) == "." else str(rel) + os.sep
112
117
  for f in sorted(filenames):
113
- if len(entries) >= max_files or total_bytes >= max_bytes:
114
- return entries, manifests, license_text, readme_head, True
115
- fp = Path(dirpath) / f
116
- try:
117
- size = fp.stat().st_size
118
- except OSError:
119
- continue
120
- if size > 100_000:
121
- continue
122
- rel_p = str(rel / f) if str(rel) != "." else f
123
- entries.append(rel_p)
124
- if f in MANIFEST_FILES or Path(f).suffix in TEXT_EXTS:
125
- try:
126
- content = fp.read_text(encoding="utf-8", errors="replace")
127
- except OSError:
128
- continue
129
- total_bytes += len(content.encode("utf-8", errors="replace"))
130
- if f in MANIFEST_FILES and f not in manifests:
131
- manifests[f] = content[:4000]
132
- if f.lower().startswith("license") and not license_text:
133
- license_text = content[:4000]
134
- if f.lower() == "readme.md" and not readme_head:
135
- readme_head = content[:4000]
136
- return entries, manifests, license_text, readme_head, False
118
+ if state.capped(max_files, max_bytes):
119
+ return state.finish(truncated=True)
120
+ _scan_file(dirpath, prefix + f, state)
121
+ return state.finish(truncated=False)
122
+
123
+
124
+ class _ScanState:
125
+ """Mutable accumulator for one scan_tree pass (keeps helpers small)."""
126
+
127
+ def __init__(self):
128
+ self.entries = []
129
+ self.total_bytes = 0
130
+ self.manifests = {}
131
+ self.license_text = ""
132
+ self.readme_head = ""
133
+
134
+ def capped(self, max_files, max_bytes):
135
+ return len(self.entries) >= max_files or self.total_bytes >= max_bytes
136
+
137
+ def finish(self, truncated):
138
+ return (
139
+ self.entries,
140
+ self.manifests,
141
+ self.license_text,
142
+ self.readme_head,
143
+ truncated,
144
+ )
145
+
146
+
147
+ def _scan_file(dirpath, rel_entry, state):
148
+ filename = os.path.basename(rel_entry)
149
+ try:
150
+ size = (Path(dirpath) / filename).stat().st_size
151
+ except OSError:
152
+ return
153
+ if size > MAX_SINGLE_FILE_BYTES:
154
+ return
155
+ state.entries.append(rel_entry)
156
+ if filename in MANIFEST_FILES or Path(filename).suffix in TEXT_EXTS:
157
+ _capture_text(dirpath, filename, state)
158
+
159
+
160
+ def _capture_text(dirpath, filename, state):
161
+ try:
162
+ content = (Path(dirpath) / filename).read_text(
163
+ encoding="utf-8", errors="replace"
164
+ )
165
+ except OSError:
166
+ return
167
+ state.total_bytes += len(content.encode("utf-8", errors="replace"))
168
+ snippet = content[:SNIPPET_CHARS]
169
+ if filename in MANIFEST_FILES and filename not in state.manifests:
170
+ state.manifests[filename] = snippet
171
+ lowered = filename.lower()
172
+ if lowered.startswith("license") and not state.license_text:
173
+ state.license_text = snippet
174
+ if lowered == "readme.md" and not state.readme_head:
175
+ state.readme_head = snippet
137
176
 
138
177
 
139
178
  def harvest_repo(
@@ -141,7 +180,7 @@ def harvest_repo(
141
180
  max_files=DEFAULT_MAX_FILES,
142
181
  max_bytes=DEFAULT_MAX_BYTES,
143
182
  timeout=DEFAULT_TIMEOUT,
144
- base_dir=".research",
183
+ base_dir=DEFAULT_BASE_DIR,
145
184
  ):
146
185
  started = datetime.now(timezone.utc).isoformat()
147
186
  tmp = tempfile.mkdtemp(prefix="darkharvest-")
@@ -162,9 +201,7 @@ def harvest_repo(
162
201
  )
163
202
  spdx = detect_license(license_text + manifests.get("package.json", ""))
164
203
  harvestable = (
165
- "depend-or-vendor"
166
- if spdx in ("MIT", "Apache-2.0", "BSD-3-Clause", "ISC")
167
- else "clean-room-rebuild-only"
204
+ "depend-or-vendor" if spdx in PERMISSIVE_SPDX else "clean-room-rebuild-only"
168
205
  )
169
206
  hasher = SourceHasher(Path(base_dir))
170
207
  cache_blob = (
@@ -174,7 +211,7 @@ def harvest_repo(
174
211
  )
175
212
  source_hash = hasher.store_source(url, cache_blob, f"Darkharvest {url}")
176
213
  warnings = []
177
- if spdx in ("GPL", "AGPL"):
214
+ if spdx in COPYLEFT_SPDX:
178
215
  warnings.append(
179
216
  "⚠️ LICENSE: strong copyleft — spec rebuild only, never vendor"
180
217
  )
@@ -209,7 +246,7 @@ def main():
209
246
  ap.add_argument("--max-files", type=int, default=DEFAULT_MAX_FILES)
210
247
  ap.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)
211
248
  ap.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
212
- ap.add_argument("--dir", default=".research")
249
+ ap.add_argument("--dir", default=DEFAULT_BASE_DIR)
213
250
  args = ap.parse_args()
214
251
 
215
252
  if not re.match(r"^https?://", args.repo):
@@ -79,11 +79,9 @@ class DDGLiteParser(HTMLParser):
79
79
  self._row_snippet = []
80
80
 
81
81
  def handle_endtag(self, tag: str):
82
- if self._capture == "title" and tag == "a":
83
- self._depth -= 1
84
- if self._depth <= 0:
85
- self._capture = None
86
- elif self._capture == "snippet" and tag == "td":
82
+ if (self._capture == "title" and tag == "a") or (
83
+ self._capture == "snippet" and tag == "td"
84
+ ):
87
85
  self._depth -= 1
88
86
  if self._depth <= 0:
89
87
  self._capture = None
@@ -99,11 +97,13 @@ class DDGLiteParser(HTMLParser):
99
97
  def flush(self):
100
98
  """Emit the in-progress row, if any. Call after ``feed()`` completes."""
101
99
  if self._row_href is not None:
102
- self.results.append({
103
- "href": self._row_href,
104
- "title": "".join(self._row_title).strip(),
105
- "snippet": "".join(self._row_snippet).strip(),
106
- })
100
+ self.results.append(
101
+ {
102
+ "href": self._row_href,
103
+ "title": "".join(self._row_title).strip(),
104
+ "snippet": "".join(self._row_snippet).strip(),
105
+ }
106
+ )
107
107
  self._row_href = None
108
108
  self._row_title = []
109
109
  self._row_snippet = []
@@ -138,7 +138,9 @@ def _fetch_ddg_html(effective_query: str) -> str:
138
138
  with urllib.request.urlopen(req, timeout=15) as resp:
139
139
  return resp.read().decode("utf-8", errors="replace")
140
140
  except Exception as e:
141
- sys.stderr.write(f"[epistemic-search] Network error fetching search results: {e}\n")
141
+ sys.stderr.write(
142
+ f"[epistemic-search] Network error fetching search results: {e}\n"
143
+ )
142
144
  return ""
143
145
 
144
146
 
@@ -154,9 +156,13 @@ def _is_domain_allowed(
154
156
  allowed_domains: Optional[List[str]] = None,
155
157
  blocked_domains: Optional[List[str]] = None,
156
158
  ) -> bool:
157
- if allowed_domains and not any(hostname == d or hostname.endswith("." + d) for d in allowed_domains):
159
+ if allowed_domains and not any(
160
+ hostname == d or hostname.endswith("." + d) for d in allowed_domains
161
+ ):
158
162
  return False
159
- if blocked_domains and any(hostname == d or hostname.endswith("." + d) for d in blocked_domains):
163
+ if blocked_domains and any(
164
+ hostname == d or hostname.endswith("." + d) for d in blocked_domains
165
+ ):
160
166
  return False
161
167
  return True
162
168
 
@@ -188,11 +194,13 @@ def search_duckduckgo(
188
194
  if not _is_domain_allowed(hostname, allowed_domains, blocked_domains):
189
195
  continue
190
196
 
191
- results.append({
192
- "title": row["title"],
193
- "url": clean_url,
194
- "snippet": row["snippet"],
195
- })
197
+ results.append(
198
+ {
199
+ "title": row["title"],
200
+ "url": clean_url,
201
+ "snippet": row["snippet"],
202
+ }
203
+ )
196
204
  if len(results) >= max_results:
197
205
  break
198
206
 
@@ -272,7 +280,9 @@ def main():
272
280
  )
273
281
 
274
282
  if not query:
275
- sys.stderr.write("Usage: search.py [options] <query>\nOr pipe JSON: echo '{\"query\":\"...\"}' | search.py\n")
283
+ sys.stderr.write(
284
+ 'Usage: search.py [options] <query>\nOr pipe JSON: echo \'{"query":"..."}\' | search.py\n'
285
+ )
276
286
  sys.exit(1)
277
287
 
278
288
  results = search_duckduckgo(
@@ -60,7 +60,9 @@ def canonical_url(url):
60
60
  query = urllib.parse.urlencode(
61
61
  sorted(urllib.parse.parse_qsl(p.query, keep_blank_values=True))
62
62
  )
63
- return urllib.parse.urlunsplit((p.scheme or "https", netloc, p.path or "/", query, ""))
63
+ return urllib.parse.urlunsplit(
64
+ (p.scheme or "https", netloc, p.path or "/", query, "")
65
+ )
64
66
  except Exception:
65
67
  return None
66
68
 
@@ -142,7 +144,9 @@ def cmd_gate(stdin_data):
142
144
  return None # miss -> allow, PostToolUse archives
143
145
  md_path, meta = found
144
146
  if not is_fresh(meta, url):
145
- log(f"stale entry for {domain_of(url)} ({meta.get('cached_at')}); allowing live fetch")
147
+ log(
148
+ f"stale entry for {domain_of(url)} ({meta.get('cached_at')}); allowing live fetch"
149
+ )
146
150
  return None # stale -> allow, PostToolUse refreshes
147
151
  try:
148
152
  content = md_path.read_text(encoding="utf-8")
@@ -174,24 +178,32 @@ def cmd_gate(stdin_data):
174
178
  }
175
179
 
176
180
 
181
+ def _text_from_value(val):
182
+ """Recursively pull the first non-empty text out of a response value."""
183
+ if isinstance(val, str) and val.strip():
184
+ return val
185
+ if isinstance(val, dict):
186
+ for sub in ("text", "content", "output"):
187
+ got = _text_from_value(val.get(sub))
188
+ if got:
189
+ return got
190
+ if isinstance(val, list):
191
+ parts = [
192
+ p.get("text", "")
193
+ for p in val
194
+ if isinstance(p, dict) and isinstance(p.get("text"), str)
195
+ ]
196
+ if parts:
197
+ return "\n".join(parts)
198
+ return ""
199
+
200
+
177
201
  def extract_response_text(stdin_data):
178
202
  """Defensively locate the tool response text across field-name variants."""
179
203
  for key in ("tool_response", "response", "tool_result", "result", "output"):
180
- val = stdin_data.get(key)
181
- if isinstance(val, str) and val.strip():
182
- return val
183
- if isinstance(val, dict):
184
- for sub in ("text", "content", "output"):
185
- inner = val.get(sub)
186
- if isinstance(inner, str) and inner.strip():
187
- return inner
188
- if isinstance(inner, list):
189
- parts = [
190
- p.get("text", "") for p in inner
191
- if isinstance(p, dict) and isinstance(p.get("text"), str)
192
- ]
193
- if parts:
194
- return "\n".join(parts)
204
+ got = _text_from_value(stdin_data.get(key))
205
+ if got:
206
+ return got
195
207
  return ""
196
208
 
197
209
 
@@ -150,7 +150,7 @@ def cmd_expansion(args):
150
150
  state["expansion"]["loops_planned"] = n
151
151
  save_state(args.run, state)
152
152
  done = 0
153
- for i in range(1, n + 1):
153
+ for _ in range(1, n + 1):
154
154
  if stop_requested(args.run):
155
155
  print(f"🛑 STOP file present — halting after {done}/{n} loops.")
156
156
  break
@@ -17,6 +17,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
17
17
  "max_iterations": 2,
18
18
  "divergence_threshold": 0.75,
19
19
  "mode": "research", # "research", "audit", "scout", "hybrid", "brainstorm", "darkharvest"
20
+ "backend": "auto", # "auto" (host-native) | "claude" | "opencode"
20
21
  "cache_raw_markdown": True,
21
22
  "license_whitelist": ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"],
22
23
  "output_dir": DEFAULT_RESEARCH_DIR,
@@ -31,10 +32,12 @@ DEFAULT_CONFIG: Dict[str, Any] = {
31
32
  # different model families/weights probe divergence that prompt-level
32
33
  # red-teaming cannot reach. `backend` is a command list, not just a model
33
34
  # string, because cross-FAMILY means non-Claude processes.
35
+ # None means "fall through to the host-native default" (claude -p on
36
+ # Claude Code, opencode run on OpenCode) — explicit values still win.
34
37
  # mock mode never builds or spawns these commands.
35
38
  "agents": {
36
- "alpha": {"backend": ["claude", "-p"], "model": None},
37
- "beta": {"backend": ["claude", "-p"], "model": None},
39
+ "alpha": {"backend": None, "model": None},
40
+ "beta": {"backend": None, "model": None},
38
41
  },
39
42
  }
40
43
 
@@ -78,6 +81,9 @@ def display_config(cfg: Dict[str, Any]):
78
81
  print(f" 🔄 Max Iterations (Depth): {cfg.get('max_iterations', 2)}")
79
82
  print(f" ⚖️ Divergence Threshold: {cfg.get('divergence_threshold', 0.75)}")
80
83
  print(f" 🎯 Operating Mode: {cfg.get('mode', 'research').upper()}")
84
+ print(
85
+ f" 🖥️ Agent Backend: {cfg.get('backend', 'auto').upper()} (auto = host-native)"
86
+ )
81
87
  print(
82
88
  f" 💾 Raw Markdown Caching: {'ENABLED' if cfg.get('cache_raw_markdown', True) else 'DISABLED'}"
83
89
  )
@@ -141,44 +147,71 @@ def run_interactive(cfg: Dict[str, Any]) -> Dict[str, Any]:
141
147
  return cfg
142
148
 
143
149
 
150
+ def _set_engine(cfg, value):
151
+ if value.lower() in ("duckduckgo", "brave", "firecrawl", "searxng"):
152
+ cfg["search_engine"] = value.lower()
153
+ return True
154
+ return False
155
+
156
+
157
+ def _set_depth(cfg, value):
158
+ try:
159
+ cfg["max_iterations"] = int(value)
160
+ return True
161
+ except ValueError:
162
+ return False
163
+
164
+
165
+ def _set_mode(cfg, value):
166
+ if value.lower() in (
167
+ "research",
168
+ "audit",
169
+ "scout",
170
+ "hybrid",
171
+ "brainstorm",
172
+ "darkharvest",
173
+ ):
174
+ cfg["mode"] = value.lower()
175
+ return True
176
+ return False
177
+
178
+
179
+ def _set_divergence(cfg, value):
180
+ try:
181
+ cfg["divergence_threshold"] = float(value)
182
+ return True
183
+ except ValueError:
184
+ return False
185
+
186
+
187
+ def _set_backend(cfg, value):
188
+ if value.lower() in ("auto", "claude", "opencode"):
189
+ cfg["backend"] = value.lower()
190
+ return True
191
+ return False
192
+
193
+
194
+ # flag spellings -> setter; each setter returns True when it changed config.
195
+ _CLI_HANDLERS = [
196
+ (("--engine", "-e"), _set_engine),
197
+ (("--depth", "-d", "--iterations"), _set_depth),
198
+ (("--mode", "-m"), _set_mode),
199
+ (("--divergence",), _set_divergence),
200
+ (("--backend",), _set_backend),
201
+ ]
202
+
203
+
144
204
  def _parse_cli_updates(args: list, cfg: Dict[str, Any]) -> bool:
145
- idx = 0
146
205
  modified = False
206
+ idx = 0
147
207
  while idx < len(args):
148
- arg = args[idx]
149
- if arg in ("--engine", "-e") and idx + 1 < len(args):
150
- engine = args[idx + 1].lower()
151
- if engine in ("duckduckgo", "brave", "firecrawl", "searxng"):
152
- cfg["search_engine"] = engine
153
- modified = True
154
- idx += 1
155
- elif arg in ("--depth", "-d", "--iterations") and idx + 1 < len(args):
156
- try:
157
- cfg["max_iterations"] = int(args[idx + 1])
158
- modified = True
159
- except ValueError:
160
- pass
161
- idx += 1
162
- elif arg in ("--mode", "-m") and idx + 1 < len(args):
163
- mode = args[idx + 1].lower()
164
- if mode in (
165
- "research",
166
- "audit",
167
- "scout",
168
- "hybrid",
169
- "brainstorm",
170
- "darkharvest",
171
- ):
172
- cfg["mode"] = mode
173
- modified = True
174
- idx += 1
175
- elif arg == "--divergence" and idx + 1 < len(args):
176
- try:
177
- cfg["divergence_threshold"] = float(args[idx + 1])
178
- modified = True
179
- except ValueError:
180
- pass
181
- idx += 1
208
+ value = args[idx + 1] if idx + 1 < len(args) else None
209
+ for flags, handler in _CLI_HANDLERS:
210
+ if args[idx] in flags:
211
+ if value is not None and handler(cfg, value):
212
+ modified = True
213
+ idx += 1
214
+ break
182
215
  idx += 1
183
216
  return modified
184
217