devcouncil 0.2.0 → 0.2.1

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 (205) hide show
  1. package/README.md +191 -253
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +6 -17
  4. package/pyproject.toml +0 -66
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -320
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -163
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/assets/__init__.py +0 -1
  23. package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
  24. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  25. package/src/devcouncil/cli/__init__.py +0 -0
  26. package/src/devcouncil/cli/commands/__init__.py +0 -0
  27. package/src/devcouncil/cli/commands/agents.py +0 -292
  28. package/src/devcouncil/cli/commands/artifacts.py +0 -54
  29. package/src/devcouncil/cli/commands/ast.py +0 -22
  30. package/src/devcouncil/cli/commands/baseline.py +0 -35
  31. package/src/devcouncil/cli/commands/check.py +0 -209
  32. package/src/devcouncil/cli/commands/config.py +0 -115
  33. package/src/devcouncil/cli/commands/cost.py +0 -57
  34. package/src/devcouncil/cli/commands/dashboard.py +0 -31
  35. package/src/devcouncil/cli/commands/doctor.py +0 -340
  36. package/src/devcouncil/cli/commands/evidence.py +0 -48
  37. package/src/devcouncil/cli/commands/go.py +0 -656
  38. package/src/devcouncil/cli/commands/handoff.py +0 -69
  39. package/src/devcouncil/cli/commands/hook.py +0 -237
  40. package/src/devcouncil/cli/commands/init.py +0 -289
  41. package/src/devcouncil/cli/commands/integrate.py +0 -1515
  42. package/src/devcouncil/cli/commands/lsp.py +0 -20
  43. package/src/devcouncil/cli/commands/map.py +0 -112
  44. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  45. package/src/devcouncil/cli/commands/plan.py +0 -488
  46. package/src/devcouncil/cli/commands/prompt.py +0 -61
  47. package/src/devcouncil/cli/commands/repair.py +0 -89
  48. package/src/devcouncil/cli/commands/report.py +0 -137
  49. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
  50. package/src/devcouncil/cli/commands/rollback.py +0 -59
  51. package/src/devcouncil/cli/commands/run.py +0 -289
  52. package/src/devcouncil/cli/commands/runs.py +0 -223
  53. package/src/devcouncil/cli/commands/scaffold.py +0 -32
  54. package/src/devcouncil/cli/commands/semantic.py +0 -47
  55. package/src/devcouncil/cli/commands/setup.py +0 -362
  56. package/src/devcouncil/cli/commands/shell.py +0 -73
  57. package/src/devcouncil/cli/commands/show.py +0 -76
  58. package/src/devcouncil/cli/commands/skills.py +0 -88
  59. package/src/devcouncil/cli/commands/status.py +0 -141
  60. package/src/devcouncil/cli/commands/tasks.py +0 -55
  61. package/src/devcouncil/cli/commands/trace.py +0 -88
  62. package/src/devcouncil/cli/commands/verify.py +0 -328
  63. package/src/devcouncil/cli/commands/version.py +0 -20
  64. package/src/devcouncil/cli/commands/watch.py +0 -574
  65. package/src/devcouncil/cli/commands/watch_fs.py +0 -40
  66. package/src/devcouncil/cli/main.py +0 -137
  67. package/src/devcouncil/council/__init__.py +0 -0
  68. package/src/devcouncil/council/prompts/__init__.py +0 -0
  69. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  70. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  71. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  72. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  73. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  74. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  75. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  76. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  77. package/src/devcouncil/domain/__init__.py +0 -0
  78. package/src/devcouncil/domain/assumption.py +0 -17
  79. package/src/devcouncil/domain/critique.py +0 -32
  80. package/src/devcouncil/domain/evidence.py +0 -47
  81. package/src/devcouncil/domain/gap.py +0 -52
  82. package/src/devcouncil/domain/requirement.py +0 -22
  83. package/src/devcouncil/domain/task.py +0 -55
  84. package/src/devcouncil/execution/__init__.py +0 -1
  85. package/src/devcouncil/execution/checkpoints.py +0 -246
  86. package/src/devcouncil/execution/context_builder.py +0 -60
  87. package/src/devcouncil/execution/executor.py +0 -15
  88. package/src/devcouncil/execution/fs_watcher.py +0 -180
  89. package/src/devcouncil/execution/handoff.py +0 -102
  90. package/src/devcouncil/execution/hook_policy.py +0 -253
  91. package/src/devcouncil/execution/patch.py +0 -77
  92. package/src/devcouncil/execution/paths.py +0 -14
  93. package/src/devcouncil/execution/permissions.py +0 -85
  94. package/src/devcouncil/execution/policy_engine.py +0 -343
  95. package/src/devcouncil/execution/prompt_builder.py +0 -671
  96. package/src/devcouncil/execution/shell_session.py +0 -225
  97. package/src/devcouncil/execution/task_runner.py +0 -170
  98. package/src/devcouncil/executors/__init__.py +0 -1
  99. package/src/devcouncil/executors/agent_registry.py +0 -575
  100. package/src/devcouncil/executors/coding_cli.py +0 -736
  101. package/src/devcouncil/executors/mini_swe.py +0 -73
  102. package/src/devcouncil/executors/native/__init__.py +0 -0
  103. package/src/devcouncil/executors/native/agent.py +0 -208
  104. package/src/devcouncil/executors/openhands.py +0 -71
  105. package/src/devcouncil/gating/__init__.py +0 -1
  106. package/src/devcouncil/gating/checks/__init__.py +0 -0
  107. package/src/devcouncil/gating/checks/clean_git.py +0 -52
  108. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  109. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  110. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
  111. package/src/devcouncil/gating/policy.py +0 -338
  112. package/src/devcouncil/hardware.py +0 -184
  113. package/src/devcouncil/indexing/__init__.py +0 -1
  114. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  115. package/src/devcouncil/indexing/graph_index.py +0 -48
  116. package/src/devcouncil/indexing/lsp.py +0 -161
  117. package/src/devcouncil/indexing/repo_mapper.py +0 -1455
  118. package/src/devcouncil/indexing/semantic_index.py +0 -205
  119. package/src/devcouncil/integrations/actions.py +0 -146
  120. package/src/devcouncil/integrations/check.py +0 -423
  121. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  122. package/src/devcouncil/integrations/github.py +0 -39
  123. package/src/devcouncil/integrations/github_intent.py +0 -142
  124. package/src/devcouncil/integrations/gitnexus.py +0 -62
  125. package/src/devcouncil/integrations/graphify.py +0 -34
  126. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  127. package/src/devcouncil/integrations/mcp/server.py +0 -2122
  128. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
  129. package/src/devcouncil/integrations/pr_comments.py +0 -62
  130. package/src/devcouncil/live/__init__.py +0 -2
  131. package/src/devcouncil/live/cards.py +0 -349
  132. package/src/devcouncil/live/models.py +0 -63
  133. package/src/devcouncil/live/repair_prompt.py +0 -83
  134. package/src/devcouncil/live/reviewer.py +0 -70
  135. package/src/devcouncil/live/signals.py +0 -135
  136. package/src/devcouncil/live/summary.py +0 -34
  137. package/src/devcouncil/live/tasks.py +0 -18
  138. package/src/devcouncil/live/transcripts.py +0 -141
  139. package/src/devcouncil/llm/__init__.py +0 -1
  140. package/src/devcouncil/llm/cache.py +0 -42
  141. package/src/devcouncil/llm/model_defaults.yaml +0 -44
  142. package/src/devcouncil/llm/provider.py +0 -627
  143. package/src/devcouncil/llm/router.py +0 -310
  144. package/src/devcouncil/optimization/__init__.py +0 -1
  145. package/src/devcouncil/optimization/gepa_agent.py +0 -318
  146. package/src/devcouncil/planning/__init__.py +0 -1
  147. package/src/devcouncil/planning/arbiter_service.py +0 -57
  148. package/src/devcouncil/planning/correction_manifest.py +0 -303
  149. package/src/devcouncil/planning/critique_service.py +0 -71
  150. package/src/devcouncil/planning/plan_service.py +0 -60
  151. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
  152. package/src/devcouncil/planning/repair_service.py +0 -39
  153. package/src/devcouncil/planning/spec_service.py +0 -70
  154. package/src/devcouncil/repo/__init__.py +0 -0
  155. package/src/devcouncil/repo/ci_scaffold.py +0 -157
  156. package/src/devcouncil/repo/gitignore.py +0 -123
  157. package/src/devcouncil/repo/sca.py +0 -374
  158. package/src/devcouncil/reporting/__init__.py +0 -0
  159. package/src/devcouncil/reporting/github_check.py +0 -32
  160. package/src/devcouncil/reporting/json_report.py +0 -30
  161. package/src/devcouncil/reporting/markdown_report.py +0 -83
  162. package/src/devcouncil/reporting/report_builder.py +0 -14
  163. package/src/devcouncil/skills/__init__.py +0 -19
  164. package/src/devcouncil/skills/library/README.md +0 -46
  165. package/src/devcouncil/skills/library/ai-training.md +0 -50
  166. package/src/devcouncil/skills/library/android.md +0 -50
  167. package/src/devcouncil/skills/library/backend.md +0 -52
  168. package/src/devcouncil/skills/library/core-engineering.md +0 -95
  169. package/src/devcouncil/skills/library/data-engineering.md +0 -47
  170. package/src/devcouncil/skills/library/desktop.md +0 -46
  171. package/src/devcouncil/skills/library/devops.md +0 -48
  172. package/src/devcouncil/skills/library/game-dev.md +0 -46
  173. package/src/devcouncil/skills/library/ios.md +0 -48
  174. package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
  175. package/src/devcouncil/skills/library/security.md +0 -48
  176. package/src/devcouncil/skills/library/systems.md +0 -48
  177. package/src/devcouncil/skills/library/web.md +0 -47
  178. package/src/devcouncil/skills/library/windows.md +0 -47
  179. package/src/devcouncil/skills/registry.py +0 -330
  180. package/src/devcouncil/storage/__init__.py +0 -0
  181. package/src/devcouncil/storage/db.py +0 -147
  182. package/src/devcouncil/storage/models.py +0 -204
  183. package/src/devcouncil/storage/native.py +0 -557
  184. package/src/devcouncil/storage/repositories.py +0 -485
  185. package/src/devcouncil/telemetry/__init__.py +0 -0
  186. package/src/devcouncil/telemetry/cost.py +0 -140
  187. package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
  188. package/src/devcouncil/telemetry/pricing.py +0 -28
  189. package/src/devcouncil/telemetry/traces.py +0 -146
  190. package/src/devcouncil/telemetry/tracker.py +0 -52
  191. package/src/devcouncil/ui/__init__.py +0 -1
  192. package/src/devcouncil/ui/dashboard.py +0 -423
  193. package/src/devcouncil/utils/__init__.py +0 -1
  194. package/src/devcouncil/utils/redaction.py +0 -147
  195. package/src/devcouncil/utils/subprocess_env.py +0 -69
  196. package/src/devcouncil/verification/__init__.py +0 -1
  197. package/src/devcouncil/verification/acceptance_compiler.py +0 -125
  198. package/src/devcouncil/verification/ad_hoc_check.py +0 -129
  199. package/src/devcouncil/verification/diff_coverage.py +0 -353
  200. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  201. package/src/devcouncil/verification/next_actions.py +0 -189
  202. package/src/devcouncil/verification/sandbox.py +0 -178
  203. package/src/devcouncil/verification/test_resolver.py +0 -91
  204. package/src/devcouncil/verification/verifier.py +0 -1548
  205. package/uv.lock +0 -1226
@@ -1,310 +0,0 @@
1
- from typing import List, Dict, Any, Type, Optional, TypeVar
2
- import copy
3
- import json
4
- import logging
5
- import asyncio
6
- from pathlib import Path
7
-
8
- from pydantic import BaseModel
9
- from devcouncil.llm.provider import Provider, LLMResponse
10
- from devcouncil.llm.cache import LLMCache
11
- from devcouncil.telemetry.tracker import TelemetryTracker
12
- from devcouncil.telemetry.traces import TraceLogger
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
-
28
-
29
- class ModelRouter:
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
- ):
42
- self.provider = provider
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
130
-
131
- async def complete_structured(
132
- self,
133
- role: str,
134
- messages: List[Dict[str, str]],
135
- schema: Type[StructuredModel],
136
- temperature: Optional[float] = None,
137
- run_id: Optional[str] = None,
138
- fallback: Optional[StructuredModel] = None,
139
- _attempt: int = 0,
140
- ) -> StructuredModel:
141
- config = self.role_config.get(role)
142
- if not config:
143
- raise ValueError(f"No config found for role: {role}")
144
-
145
- model = config["model"]
146
- temp = temperature if temperature is not None else config.get("temperature", 0.0)
147
-
148
- # Deep-copy to avoid mutating the caller's messages list
149
- msgs = copy.deepcopy(messages)
150
-
151
- # Add schema instructions to system or user message
152
- schema_json = json.dumps(schema.model_json_schema(), indent=2)
153
- instruction = f"\n\nYou MUST output a JSON object matching this schema:\n{schema_json}"
154
-
155
- found_system = False
156
- for msg in msgs:
157
- if msg["role"] == "system":
158
- msg["content"] += instruction
159
- found_system = True
160
- break
161
-
162
- if not found_system:
163
- msgs.insert(0, {"role": "system", "content": f"You are a helpful assistant.{instruction}"})
164
-
165
- logger.info("LLM call: role=%s model=%s run_id=%s", role, model, run_id)
166
-
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()
177
-
178
- # Check cache first
179
- response = cache.get(model, msgs, temp, True, provider_fp)
180
- cache_hit = response is not None
181
-
182
- if not response:
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.")
189
-
190
- if not cache_hit:
191
- tracker.log_usage(model, response.usage, local=provider_local)
192
-
193
- logger.info(
194
- "LLM response: role=%s model=%s tokens=%s",
195
- role, response.model, response.usage,
196
- )
197
-
198
- try:
199
- # Extract JSON from fences/surrounding prose (balanced-aware).
200
- content = self._extract_json(response.content)
201
- data = json.loads(content)
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
206
- except Exception as e:
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
- )
220
-
221
- # Healing attempt: Ask the model to fix its own JSON
222
- healing_prompt = f"""
223
- The following JSON was returned but failed to parse or validate against the schema.
224
- Error: {str(e)}
225
- Content:
226
- {response.content}
227
-
228
- Please return the corrected JSON object only. No prose.
229
- """
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
235
- try:
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)
245
- data = json.loads(healed_content)
246
- result = schema.model_validate(data)
247
- cache.set(model, msgs, temp, True, healed_response, provider_fp)
248
- return result
249
- except Exception as final_e:
250
- logger.error(f"Healing failed for {role}: {final_e}")
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
- )
@@ -1 +0,0 @@
1
- """Optimization integrations for DevCouncil prompt and workflow assets."""
@@ -1,318 +0,0 @@
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")
@@ -1 +0,0 @@
1
-
@@ -1,57 +0,0 @@
1
- from typing import List, Dict
2
- from pydantic import BaseModel
3
- from devcouncil.domain.requirement import Requirement
4
- from devcouncil.domain.task import Task
5
- from devcouncil.llm.router import ModelRouter
6
-
7
- class ArbiterDecision(BaseModel):
8
- accepted_finding_ids: List[str]
9
- rejected_finding_ids: List[Dict[str, str]] # id, reason
10
- final_requirements: List[Requirement]
11
- final_tasks: List[Task]
12
-
13
- class ArbiterService:
14
- def __init__(self, router: ModelRouter):
15
- self.router = router
16
-
17
- async def arbitrate(
18
- self,
19
- goal: str,
20
- requirements_json: str,
21
- plan_a_json: str,
22
- plan_b_json: str,
23
- critique_a_json: str,
24
- critique_b_json: str,
25
- rebuttal_a_json: str,
26
- rebuttal_b_json: str
27
- ) -> ArbiterDecision:
28
- prompt = f"""
29
- Goal: {goal}
30
-
31
- Initial Requirements:
32
- {requirements_json}
33
-
34
- Plan A: {plan_a_json}
35
- Plan B: {plan_b_json}
36
-
37
- Critique of Plan B by Critic A: {critique_a_json}
38
- Critique of Plan A by Critic B: {critique_b_json}
39
-
40
- Rebuttal of Critic B by Planner A: {rebuttal_a_json}
41
- Rebuttal of Critic A by Planner B: {rebuttal_b_json}
42
-
43
- You are the arbiter engineering manager. Your goal is to produce the final, definitive set of requirements and tasks.
44
- - You do not decide by vibes.
45
- - High-severity unrefuted findings from critics must be incorporated into the final requirements or tasks.
46
- - If a planner successfully rebutted a finding, you may skip it.
47
- - Produce a single, coherent task graph.
48
- """
49
- messages = [
50
- {"role": "user", "content": prompt}
51
- ]
52
-
53
- return await self.router.complete_structured(
54
- role="arbiter",
55
- messages=messages,
56
- schema=ArbiterDecision
57
- )