devcouncil 0.1.1 → 0.2.0

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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,4 +1,4 @@
1
- from typing import List, Dict, Any, Type, Optional
1
+ from typing import List, Dict, Any, Type, Optional, TypeVar
2
2
  import copy
3
3
  import json
4
4
  import logging
@@ -6,25 +6,138 @@ import asyncio
6
6
  from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel
9
- from devcouncil.llm.provider import Provider
9
+ from devcouncil.llm.provider import Provider, LLMResponse
10
10
  from devcouncil.llm.cache import LLMCache
11
11
  from devcouncil.telemetry.tracker import TelemetryTracker
12
+ from devcouncil.telemetry.traces import TraceLogger
12
13
 
13
14
  logger = logging.getLogger(__name__)
15
+ StructuredModel = TypeVar("StructuredModel", bound=BaseModel)
16
+
17
+
18
+ class StructuredOutputError(RuntimeError):
19
+ """A model could not produce valid structured output for a role, even after
20
+ a healing retry. Carries the role/model so the CLI can give actionable advice
21
+ (usually: switch that role to a more capable model)."""
22
+
23
+ def __init__(self, message: str, *, role: str, model: str):
24
+ super().__init__(message)
25
+ self.role = role
26
+ self.model = model
27
+
14
28
 
15
29
  class ModelRouter:
16
- def __init__(self, provider: Provider, role_config: Dict[str, Dict[str, Any]]):
30
+ # Independent fresh attempts at producing valid structured output before
31
+ # giving up. Even capable models occasionally emit malformed JSON; a second
32
+ # clean attempt usually succeeds. Malformed responses are never cached, so a
33
+ # retry is genuinely fresh rather than re-serving the same bad JSON.
34
+ STRUCTURED_ATTEMPTS = 2
35
+
36
+ def __init__(
37
+ self,
38
+ provider: Provider,
39
+ role_config: Dict[str, Dict[str, Any]],
40
+ project_root: Path = Path("."),
41
+ ):
17
42
  self.provider = provider
18
43
  self.role_config = role_config
44
+ self.project_root = project_root
45
+
46
+ @staticmethod
47
+ def _extract_json(content: str) -> str:
48
+ """Best-effort extraction of a JSON document from a model response.
49
+
50
+ Handles the common ways a model wraps valid JSON: triple-backtick fences and
51
+ surrounding prose ("Here you go: {...} thanks"). Strips fences, returns the
52
+ whole thing if it already parses, otherwise scans for the first balanced
53
+ object/array (string- and escape-aware so braces inside string values don't
54
+ confuse it). Falls back to the de-fenced text so the existing healing path
55
+ still produces a meaningful error. A strict superset of plain fence-stripping
56
+ — clean/fenced JSON is returned unchanged."""
57
+ text = content.strip()
58
+ if "```json" in text:
59
+ text = text.split("```json", 1)[1].split("```", 1)[0].strip()
60
+ elif "```" in text:
61
+ text = text.split("```", 1)[1].split("```", 1)[0].strip()
62
+ try:
63
+ json.loads(text)
64
+ return text
65
+ except Exception:
66
+ pass
67
+ for opener, closer in (("{", "}"), ("[", "]")):
68
+ start = text.find(opener)
69
+ if start == -1:
70
+ continue
71
+ depth = 0
72
+ in_str = False
73
+ escaped = False
74
+ for i in range(start, len(text)):
75
+ ch = text[i]
76
+ if in_str:
77
+ if escaped:
78
+ escaped = False
79
+ elif ch == "\\":
80
+ escaped = True
81
+ elif ch == '"':
82
+ in_str = False
83
+ continue
84
+ if ch == '"':
85
+ in_str = True
86
+ elif ch == opener:
87
+ depth += 1
88
+ elif ch == closer:
89
+ depth -= 1
90
+ if depth == 0:
91
+ candidate = text[start:i + 1]
92
+ try:
93
+ json.loads(candidate)
94
+ return candidate
95
+ except Exception:
96
+ break
97
+ return text
98
+
99
+ async def _complete_with_retry(
100
+ self,
101
+ *,
102
+ model: str,
103
+ messages: List[Dict[str, str]],
104
+ temperature: float,
105
+ run_id: Optional[str],
106
+ attempts: int = 3,
107
+ ) -> "LLMResponse":
108
+ """Provider completion with bounded exponential-backoff retry. Used for BOTH the
109
+ initial call and the healing call so a transient fault in either is retried (and,
110
+ if still failing, surfaced to the caller's fallback logic) rather than aborting
111
+ the run."""
112
+ for attempt in range(attempts):
113
+ try:
114
+ return await self.provider.complete(
115
+ model=model,
116
+ messages=messages,
117
+ temperature=temperature,
118
+ json_mode=True,
119
+ run_id=run_id,
120
+ )
121
+ except Exception as exc:
122
+ if attempt == attempts - 1:
123
+ raise
124
+ logger.warning(
125
+ "LLM request failed (attempt %d/%d): %s. Retrying...",
126
+ attempt + 1, attempts, exc,
127
+ )
128
+ await asyncio.sleep(2 ** attempt)
129
+ raise RuntimeError("unreachable") # loop either returns or raises
19
130
 
20
131
  async def complete_structured(
21
132
  self,
22
133
  role: str,
23
134
  messages: List[Dict[str, str]],
24
- schema: Type[BaseModel],
135
+ schema: Type[StructuredModel],
25
136
  temperature: Optional[float] = None,
26
137
  run_id: Optional[str] = None,
27
- ) -> BaseModel:
138
+ fallback: Optional[StructuredModel] = None,
139
+ _attempt: int = 0,
140
+ ) -> StructuredModel:
28
141
  config = self.role_config.get(role)
29
142
  if not config:
30
143
  raise ValueError(f"No config found for role: {role}")
@@ -51,33 +164,31 @@ class ModelRouter:
51
164
 
52
165
  logger.info("LLM call: role=%s model=%s run_id=%s", role, model, run_id)
53
166
 
54
- project_root = Path(".")
55
- cache = LLMCache(project_root)
56
- tracker = TelemetryTracker(project_root)
167
+ cache = LLMCache(self.project_root)
168
+ tracker = TelemetryTracker(self.project_root)
169
+ traces = TraceLogger(self.project_root)
170
+
171
+ # Provider knobs (e.g. Ollama num_ctx / base_url) that change the output for an
172
+ # identical prompt must be part of the cache key, else raising OLLAMA_NUM_CTX
173
+ # after a truncated answer would keep serving the stale response.
174
+ provider_fp = self.provider.cache_fingerprint()
175
+ # Zero local (Ollama) usage by provider so telemetry matches the cost ledger.
176
+ provider_local = self.provider.is_local_cost_free()
57
177
 
58
178
  # Check cache first
59
- response = cache.get(model, msgs, temp, True)
179
+ response = cache.get(model, msgs, temp, True, provider_fp)
60
180
  cache_hit = response is not None
61
181
 
62
182
  if not response:
63
- for attempt in range(3):
64
- try:
65
- response = await self.provider.complete(
66
- model=model,
67
- messages=msgs,
68
- temperature=temp,
69
- json_mode=True
70
- )
71
- cache.set(model, msgs, temp, True, response)
72
- break
73
- except Exception as e:
74
- if attempt == 2:
75
- raise
76
- logger.warning(f"LLM request failed (attempt {attempt+1}): {e}. Retrying...")
77
- await asyncio.sleep(2 ** attempt)
183
+ response = await self._complete_with_retry(
184
+ model=model, messages=msgs, temperature=temp, run_id=run_id
185
+ )
186
+
187
+ if response is None:
188
+ raise RuntimeError(f"LLM request for role {role} did not return a response.")
78
189
 
79
190
  if not cache_hit:
80
- tracker.log_usage(model, response.usage)
191
+ tracker.log_usage(model, response.usage, local=provider_local)
81
192
 
82
193
  logger.info(
83
194
  "LLM response: role=%s model=%s tokens=%s",
@@ -85,17 +196,27 @@ class ModelRouter:
85
196
  )
86
197
 
87
198
  try:
88
- # Attempt to find JSON block if it's wrapped in markdown
89
- content = response.content.strip()
90
- if "```json" in content:
91
- content = content.split("```json")[1].split("```")[0].strip()
92
- elif "```" in content:
93
- content = content.split("```")[1].split("```")[0].strip()
94
-
199
+ # Extract JSON from fences/surrounding prose (balanced-aware).
200
+ content = self._extract_json(response.content)
95
201
  data = json.loads(content)
96
- return schema.model_validate(data)
202
+ result = schema.model_validate(data)
203
+ if not cache_hit:
204
+ cache.set(model, msgs, temp, True, response, provider_fp) # cache only validated output
205
+ return result
97
206
  except Exception as e:
98
207
  logger.warning(f"Initial parse failed for {role}, attempting healing: {e}")
208
+ traces.log_event(
209
+ "llm_structured_parse_failed",
210
+ {
211
+ "role": role,
212
+ "model": response.model,
213
+ "schema": schema.__name__,
214
+ "error": str(e),
215
+ "content_preview": response.content[:500],
216
+ },
217
+ run_id=run_id,
218
+ summary=f"Structured response parse failed for {role}; attempting repair.",
219
+ )
99
220
 
100
221
  # Healing attempt: Ask the model to fix its own JSON
101
222
  healing_prompt = f"""
@@ -106,20 +227,84 @@ Content:
106
227
 
107
228
  Please return the corrected JSON object only. No prose.
108
229
  """
109
- # We use a lower temperature for healing
110
- healed_response = await self.provider.complete(
111
- model=model,
112
- messages=[{"role": "user", "content": healing_prompt}],
113
- temperature=0.0,
114
- json_mode=True
115
- )
116
-
230
+ # The healing completion runs INSIDE this try (with the same retry/backoff as
231
+ # the initial call). A transient failure here (429/timeout) must be treated as
232
+ # "healing failed" so it routes into the fresh-attempt/fallback logic below,
233
+ # not propagate as a raw provider error that defeats the supplied fallback.
234
+ healed_response = None
117
235
  try:
118
- healed_content = healed_response.content.strip()
119
- if "```json" in healed_content:
120
- healed_content = healed_content.split("```json")[1].split("```")[0].strip()
236
+ # We use a lower temperature for healing
237
+ healed_response = await self._complete_with_retry(
238
+ model=model,
239
+ messages=[{"role": "user", "content": healing_prompt}],
240
+ temperature=0.0,
241
+ run_id=run_id,
242
+ )
243
+ tracker.log_usage(healed_response.model, healed_response.usage, local=provider_local)
244
+ healed_content = self._extract_json(healed_response.content)
121
245
  data = json.loads(healed_content)
122
- return schema.model_validate(data)
246
+ result = schema.model_validate(data)
247
+ cache.set(model, msgs, temp, True, healed_response, provider_fp)
248
+ return result
123
249
  except Exception as final_e:
124
250
  logger.error(f"Healing failed for {role}: {final_e}")
125
- raise ValueError(f"Failed to parse or validate LLM response after healing: {final_e}\nContent (truncated): {response.content[:200]}...")
251
+ traces.log_event(
252
+ "llm_structured_parse_repair_failed",
253
+ {
254
+ "role": role,
255
+ "model": healed_response.model if healed_response else model,
256
+ "schema": schema.__name__,
257
+ "error": str(final_e),
258
+ "original_content_preview": response.content[:500],
259
+ "healed_content_preview": healed_response.content[:500] if healed_response else "(healing request failed)",
260
+ },
261
+ run_id=run_id,
262
+ summary=f"Structured response repair failed for {role}.",
263
+ )
264
+ if _attempt + 1 < self.STRUCTURED_ATTEMPTS:
265
+ # A fresh, independent attempt often succeeds where one bad draft
266
+ # (plus its repair) failed. The malformed response was never
267
+ # cached, so this re-runs the completion rather than re-reading it.
268
+ # Prepend a strict JSON-only instruction (on a copy, so the caller's
269
+ # messages are untouched) to nudge the retry toward parseable output.
270
+ logger.warning(
271
+ "Structured output failed for role '%s'; retrying fresh "
272
+ "(attempt %d/%d).",
273
+ role, _attempt + 2, self.STRUCTURED_ATTEMPTS,
274
+ )
275
+ strict_messages = copy.deepcopy(messages)
276
+ strict_messages.insert(0, {
277
+ "role": "system",
278
+ "content": (
279
+ "Respond with a single valid JSON object only — no prose, no "
280
+ "markdown fences, no trailing text. It must parse with a strict "
281
+ "JSON parser and match the requested schema."
282
+ ),
283
+ })
284
+ return await self.complete_structured(
285
+ role,
286
+ strict_messages,
287
+ schema,
288
+ temperature=temperature,
289
+ run_id=run_id,
290
+ fallback=fallback,
291
+ _attempt=_attempt + 1,
292
+ )
293
+ if fallback is not None:
294
+ # Degradable role (e.g. critique/rebuttal/enhancement): keep
295
+ # planning alive on weaker models instead of crashing the run.
296
+ logger.warning(
297
+ "Role '%s' (model '%s') could not produce valid %s; "
298
+ "using a safe fallback so planning can continue.",
299
+ role, model, schema.__name__,
300
+ )
301
+ return fallback
302
+ raise StructuredOutputError(
303
+ f"Model '{model}' for role '{role}' could not produce valid "
304
+ f"{schema.__name__} JSON, even after a repair attempt. "
305
+ f"Use a more capable model for this role "
306
+ f"(e.g. 'dev config models --role {role} --model <model>'). "
307
+ f"Parser error: {final_e}",
308
+ role=role,
309
+ model=model,
310
+ )
@@ -0,0 +1 @@
1
+ """Optimization integrations for DevCouncil prompt and workflow assets."""
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import json
5
+ import sys
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Callable
11
+
12
+ import yaml # type: ignore[import-untyped]
13
+
14
+ from devcouncil.executors.agent_registry import (
15
+ load_agent_profiles,
16
+ load_cli_agent_specs,
17
+ normalize_agent_name,
18
+ )
19
+
20
+
21
+ DEFAULT_OBJECTIVE = (
22
+ "Optimize the DevCouncil CLI-agent profile prompt preamble so coding agents stay inside "
23
+ "planned scope, run the expected verification commands, preserve evidence, avoid destructive "
24
+ "or out-of-policy changes, and address the observed failures in the evaluation examples."
25
+ )
26
+
27
+
28
+ class GepaUnavailableError(RuntimeError):
29
+ """Raised when the optional GEPA package is not installed."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AgentPromptOptimizationResult:
34
+ agent: str
35
+ profile_name: str
36
+ seed_preamble: str
37
+ best_preamble: str
38
+ best_score: float | None
39
+ artifact_path: Path
40
+ applied: bool
41
+
42
+
43
+ def optimize_agent_profile(
44
+ *,
45
+ project_root: Path,
46
+ agent: str,
47
+ profile_name: str,
48
+ evals_path: Path,
49
+ max_metric_calls: int = 40,
50
+ objective: str | None = None,
51
+ apply: bool = False,
52
+ output_path: Path | None = None,
53
+ ) -> AgentPromptOptimizationResult:
54
+ """Run GEPA over a CLI-agent profile prompt preamble.
55
+
56
+ GEPA needs an evaluator. DevCouncil's evaluator consumes an offline JSON/JSONL
57
+ dataset of observed agent failures and desired prompt behavior, then feeds that
58
+ context back to GEPA as actionable side information.
59
+ """
60
+
61
+ root = project_root.expanduser().resolve()
62
+ normalized_agent = normalize_agent_name(agent)
63
+ if normalized_agent not in load_cli_agent_specs(root):
64
+ raise ValueError(f"Agent '{agent}' is not registered or supported.")
65
+
66
+ profiles = load_agent_profiles(root)
67
+ profile = profiles.get(profile_name)
68
+ if profile is None:
69
+ raise ValueError(f"Profile '{profile_name}' is not configured.")
70
+
71
+ resolved_evals_path = evals_path.expanduser()
72
+ if not resolved_evals_path.is_absolute():
73
+ resolved_evals_path = root / resolved_evals_path
74
+ dataset = load_agent_eval_dataset(resolved_evals_path)
75
+ gepa_module = _load_gepa_optimize_anything()
76
+ seed_preamble = profile.prompt_preamble or ""
77
+ effective_objective = objective or DEFAULT_OBJECTIVE
78
+
79
+ def evaluator(candidate: Any, example: dict[str, Any] | None = None) -> float:
80
+ if example is None:
81
+ scores = [
82
+ _score_candidate(
83
+ candidate,
84
+ item,
85
+ log=getattr(gepa_module, "log", lambda message: None),
86
+ )
87
+ for item in dataset
88
+ ]
89
+ return sum(scores) / len(scores)
90
+ return _score_candidate(
91
+ candidate,
92
+ example,
93
+ log=getattr(gepa_module, "log", lambda message: None),
94
+ )
95
+
96
+ engine = gepa_module.EngineConfig(max_metric_calls=max_metric_calls)
97
+ config = gepa_module.GEPAConfig(engine=engine)
98
+ gepa_result = gepa_module.optimize_anything(
99
+ seed_candidate=seed_preamble,
100
+ evaluator=evaluator,
101
+ dataset=dataset,
102
+ objective=effective_objective,
103
+ config=config,
104
+ )
105
+
106
+ best_preamble = _candidate_to_text(_best_candidate(gepa_result)).strip()
107
+ if not best_preamble:
108
+ best_preamble = seed_preamble
109
+ best_score = _best_score(gepa_result)
110
+
111
+ artifact_path = output_path or _default_artifact_path(root, normalized_agent, profile_name)
112
+ artifact_path = artifact_path.expanduser()
113
+ if not artifact_path.is_absolute():
114
+ artifact_path = root / artifact_path
115
+ _write_result_artifact(
116
+ artifact_path,
117
+ {
118
+ "optimizer": "gepa.optimize_anything",
119
+ "agent": normalized_agent,
120
+ "profile": profile_name,
121
+ "objective": effective_objective,
122
+ "evals_path": str(resolved_evals_path),
123
+ "max_metric_calls": max_metric_calls,
124
+ "example_count": len(dataset),
125
+ "seed_preamble": seed_preamble,
126
+ "best_preamble": best_preamble,
127
+ "best_score": best_score,
128
+ "applied": apply,
129
+ "created_at": datetime.now(timezone.utc).isoformat(),
130
+ },
131
+ )
132
+
133
+ if apply:
134
+ _apply_profile_preamble(root, profile_name, best_preamble)
135
+
136
+ return AgentPromptOptimizationResult(
137
+ agent=normalized_agent,
138
+ profile_name=profile_name,
139
+ seed_preamble=seed_preamble,
140
+ best_preamble=best_preamble,
141
+ best_score=best_score,
142
+ artifact_path=artifact_path,
143
+ applied=apply,
144
+ )
145
+
146
+
147
+ def load_agent_eval_dataset(path: Path) -> list[dict[str, Any]]:
148
+ evals_path = path.expanduser()
149
+ if not evals_path.exists():
150
+ raise ValueError(f"GEPA eval dataset not found: {evals_path}")
151
+
152
+ if evals_path.suffix.lower() == ".jsonl":
153
+ examples = [
154
+ json.loads(line)
155
+ for line in evals_path.read_text(encoding="utf-8").splitlines()
156
+ if line.strip()
157
+ ]
158
+ else:
159
+ raw = json.loads(evals_path.read_text(encoding="utf-8"))
160
+ if isinstance(raw, dict):
161
+ if isinstance(raw.get("examples"), list):
162
+ examples = raw["examples"]
163
+ elif isinstance(raw.get("dataset"), list):
164
+ examples = raw["dataset"]
165
+ else:
166
+ examples = [raw]
167
+ elif isinstance(raw, list):
168
+ examples = raw
169
+ else:
170
+ raise ValueError("GEPA eval dataset must be a JSON object, JSON array, or JSONL file.")
171
+
172
+ normalized: list[dict[str, Any]] = []
173
+ for index, item in enumerate(examples, start=1):
174
+ if not isinstance(item, dict):
175
+ raise ValueError("Every GEPA eval example must be a JSON object.")
176
+ example = dict(item)
177
+ example.setdefault("id", f"example-{index}")
178
+ normalized.append(example)
179
+
180
+ if not normalized:
181
+ raise ValueError("GEPA eval dataset must contain at least one example.")
182
+ return normalized
183
+
184
+
185
+ def _load_gepa_optimize_anything() -> Any:
186
+ injected_module = sys.modules.get("gepa.optimize_anything")
187
+ if injected_module is not None:
188
+ return injected_module
189
+ try:
190
+ return importlib.import_module("gepa.optimize_anything")
191
+ except ImportError as exc:
192
+ raise GepaUnavailableError(
193
+ "GEPA is not installed in this environment. Reinstall or sync DevCouncil dependencies, "
194
+ "then rerun `dev agents optimize`."
195
+ ) from exc
196
+
197
+
198
+ def _score_candidate(candidate: Any, example: dict[str, Any], *, log: Callable[[str], None]) -> float:
199
+ candidate_text = _candidate_to_text(candidate)
200
+ lower_candidate = candidate_text.lower()
201
+ required_terms = _string_list(example, "required_terms", "expected_prompt_fragments", "must_include")
202
+ forbidden_terms = _string_list(example, "forbidden_terms", "must_avoid")
203
+
204
+ log(f"Example {example.get('id')}: {example.get('task', '')}".strip())
205
+ for key in ("observed_failure", "desired_behavior", "feedback", "rubric"):
206
+ value = example.get(key)
207
+ if value:
208
+ log(f"{key.replace('_', ' ').title()}: {value}")
209
+ if required_terms:
210
+ log(f"Required prompt terms: {', '.join(required_terms)}")
211
+ if forbidden_terms:
212
+ log(f"Forbidden prompt terms: {', '.join(forbidden_terms)}")
213
+
214
+ if not candidate_text.strip():
215
+ log("Candidate preamble is empty.")
216
+ return 0.0
217
+
218
+ required_hits = [term for term in required_terms if term.lower() in lower_candidate]
219
+ forbidden_hits = [term for term in forbidden_terms if term.lower() in lower_candidate]
220
+ missing_terms = [term for term in required_terms if term not in required_hits]
221
+ if missing_terms:
222
+ log(f"Missing required terms: {', '.join(missing_terms)}")
223
+ if forbidden_hits:
224
+ log(f"Forbidden terms present: {', '.join(forbidden_hits)}")
225
+
226
+ required_score = len(required_hits) / len(required_terms) if required_terms else 0.5
227
+ forbidden_score = 1.0 - (len(forbidden_hits) / len(forbidden_terms)) if forbidden_terms else 1.0
228
+ length_score = _length_score(candidate_text)
229
+ score = (required_score * 0.65) + (forbidden_score * 0.25) + (length_score * 0.10)
230
+ return max(0.0, min(1.0, score))
231
+
232
+
233
+ def _candidate_to_text(candidate: Any) -> str:
234
+ if candidate is None:
235
+ return ""
236
+ if isinstance(candidate, str):
237
+ return candidate
238
+ if isinstance(candidate, dict):
239
+ for key in ("prompt_preamble", "preamble", "prompt", "system_prompt", "text"):
240
+ value = candidate.get(key)
241
+ if isinstance(value, str):
242
+ return value
243
+ return json.dumps(candidate, sort_keys=True)
244
+ for attr in ("prompt_preamble", "preamble", "prompt", "system_prompt", "text"):
245
+ value = getattr(candidate, attr, None)
246
+ if isinstance(value, str):
247
+ return value
248
+ return str(candidate)
249
+
250
+
251
+ def _best_candidate(result: Any) -> Any:
252
+ if isinstance(result, dict):
253
+ for key in ("best_candidate", "candidate", "best"):
254
+ if key in result:
255
+ return result[key]
256
+ return result
257
+ for attr in ("best_candidate", "candidate", "best"):
258
+ if hasattr(result, attr):
259
+ return getattr(result, attr)
260
+ return result
261
+
262
+
263
+ def _best_score(result: Any) -> float | None:
264
+ raw_score: Any
265
+ if isinstance(result, dict):
266
+ raw_score = result.get("best_score", result.get("score"))
267
+ else:
268
+ raw_score = getattr(result, "best_score", getattr(result, "score", None))
269
+ if raw_score is None:
270
+ return None
271
+ try:
272
+ return float(raw_score)
273
+ except (TypeError, ValueError):
274
+ return None
275
+
276
+
277
+ def _string_list(example: dict[str, Any], *keys: str) -> list[str]:
278
+ values: list[str] = []
279
+ for key in keys:
280
+ raw = example.get(key)
281
+ if raw is None:
282
+ continue
283
+ if isinstance(raw, str):
284
+ values.append(raw)
285
+ elif isinstance(raw, Iterable):
286
+ values.extend(str(item) for item in raw if str(item).strip())
287
+ return [value.strip() for value in values if value.strip()]
288
+
289
+
290
+ def _length_score(candidate_text: str) -> float:
291
+ words = candidate_text.split()
292
+ if 10 <= len(words) <= 180:
293
+ return 1.0
294
+ if len(words) < 10:
295
+ return len(words) / 10
296
+ return max(0.0, 1.0 - ((len(words) - 180) / 180))
297
+
298
+
299
+ def _default_artifact_path(project_root: Path, agent: str, profile_name: str) -> Path:
300
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
301
+ safe_profile = profile_name.replace("/", "-").replace("\\", "-")
302
+ return project_root / ".devcouncil" / "optimizations" / f"{timestamp}-{agent}-{safe_profile}-gepa.json"
303
+
304
+
305
+ def _write_result_artifact(path: Path, payload: dict[str, Any]) -> None:
306
+ path.parent.mkdir(parents=True, exist_ok=True)
307
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
308
+
309
+
310
+ def _apply_profile_preamble(project_root: Path, profile_name: str, best_preamble: str) -> None:
311
+ config_path = project_root / ".devcouncil" / "config.yaml"
312
+ raw_config = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
313
+ raw_config = raw_config or {}
314
+ profiles = raw_config.setdefault("integrations", {}).setdefault("cli_agents", {}).setdefault("profiles", {})
315
+ profile = profiles.setdefault(profile_name, {})
316
+ profile["prompt_preamble"] = best_preamble
317
+ config_path.parent.mkdir(parents=True, exist_ok=True)
318
+ config_path.write_text(yaml.safe_dump(raw_config, sort_keys=False), encoding="utf-8")