devcouncil 0.2.0 → 0.3.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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import typer
2
3
  from rich.console import Console
3
4
  from pathlib import Path
@@ -23,6 +24,7 @@ from devcouncil.cli.commands.init import initialize_project
23
24
  from devcouncil.telemetry.traces import TraceLogger
24
25
 
25
26
  console = Console()
27
+ logger = logging.getLogger(__name__)
26
28
  CODING_EXECUTOR_ALIASES = {name: name for name in BUILTIN_CODING_EXECUTOR_NAMES} | AGENT_ALIASES
27
29
 
28
30
  CODING_EXECUTORS = set(CODING_EXECUTOR_ALIASES.keys())
@@ -58,8 +60,14 @@ def _verify_after_execution(session, task, reqs, router=None, project_root: Path
58
60
  """Run deterministic verification after an automated executor finishes."""
59
61
  import asyncio
60
62
 
63
+ logger.info("Verifying task %s (router=%s)", task.id, "yes" if router else "no")
61
64
  verifier = Verifier(project_root, router=router)
62
65
  gaps, evidence = asyncio.run(verifier.verify_task(task, reqs))
66
+ blocking = [g for g in gaps if g.blocking]
67
+ logger.info(
68
+ "Verification of %s: %d gap(s) (%d blocking), %d evidence item(s)",
69
+ task.id, len(gaps), len(blocking), len(evidence),
70
+ )
63
71
 
64
72
  gap_repo = GapRepository(session)
65
73
  evidence_repo = EvidenceRepository(session)
@@ -81,6 +89,89 @@ def _verify_after_execution(session, task, reqs, router=None, project_root: Path
81
89
  return task.status == "verified"
82
90
 
83
91
 
92
+ def _build_verification_router(project_root: Path):
93
+ """Best-effort ``ModelRouter`` for LLM-backed verification after a coding-agent run.
94
+
95
+ Without a router the ``Verifier`` runs deterministic checks only (no
96
+ ``implementation_reviewer`` review, no acceptance-criterion compilation). The native
97
+ executor already builds a router to *run* the agent and reuses it for verification;
98
+ CLI coding agents (claude, codex, …) don't need one to execute, so they previously
99
+ verified without the LLM review at all. Build one here so the review gate guides and
100
+ monitors execution for those agents too. Per-role provider config means these review
101
+ roles can run on a different provider than planning (e.g. local Ollama).
102
+
103
+ Returns ``None`` when no provider/API key is configured so verification degrades to
104
+ deterministic-only instead of erroring — the LLM review is an enhancement, not a
105
+ hard requirement of running a task.
106
+ """
107
+ try:
108
+ config = load_config(project_root)
109
+ validate_model_provider(config.models.provider)
110
+ api_key = get_api_key(config.models.provider, project_root)
111
+ provider = create_provider(
112
+ config.models.provider, api_key, project_root=project_root, provider_prefs=config.provider
113
+ )
114
+ role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
115
+ return ModelRouter(provider, role_config, project_root=project_root)
116
+ except Exception:
117
+ return None
118
+
119
+
120
+ def _run_live_review_after_execution(project_root: Path, client: str, task_id: str | None) -> None:
121
+ """Critique the coding agent's latest turn with the ``live_reviewer`` role.
122
+
123
+ This is what makes live review actually fire during ``dev e2e``/``dev run`` (previously
124
+ it only ran via ``dev watch``). It produces an advisory critique card — it does NOT
125
+ gate the task; the deterministic/LLM verifier already does that. The card feeds the
126
+ final report's live-review summary and is routed by per-role config (e.g. a local
127
+ Ollama ``live_reviewer`` while planning runs on OpenRouter).
128
+
129
+ The transcript is resolved the same way ``dev watch`` does — the client's NATIVE
130
+ session log (e.g. claude's projects JSONL), discovered for this project root — not the
131
+ executor's streamed ``transcript.txt`` (which only exists with --stream and isn't the
132
+ structured turn format ``latest_assistant_turn`` parses).
133
+
134
+ Best-effort and opt-outable: skipped when ``integrations.live_review.enabled`` is
135
+ false, when no transcript is found, or on any error — a live-review hiccup must never
136
+ fail the run.
137
+ """
138
+ import asyncio
139
+
140
+ try:
141
+ if not load_config(project_root).integrations.live_review.enabled:
142
+ return
143
+ from devcouncil.cli.commands.watch import (
144
+ _resolve_transcript,
145
+ _review_turn,
146
+ _save_card_once,
147
+ _log_card_reviewed,
148
+ )
149
+ from devcouncil.live.transcripts import latest_assistant_turn
150
+
151
+ transcript = _resolve_transcript(project_root, client, latest=True)
152
+ if transcript is None or not transcript.exists():
153
+ return
154
+ turn = latest_assistant_turn(transcript, client=client)
155
+ if turn is None:
156
+ return
157
+ card = asyncio.run(_review_turn(turn, project_root, client, use_llm=True, task_id=task_id))
158
+ saved_path, duplicate = _save_card_once(project_root, card, persist=True, force=False)
159
+ if saved_path:
160
+ _log_card_reviewed(project_root, card, saved_path, duplicate=duplicate, source="e2e")
161
+ console.print(f"[dim]Live review ({card.verdict}): {saved_path}[/dim]")
162
+ except Exception as exc: # noqa: BLE001 - advisory; never fail the run on a review hiccup
163
+ console.print(f"[yellow]Live review skipped: {exc}[/yellow]")
164
+
165
+
166
+ def _log_exec_outcome(executor: str, task_id: str, *, verified: bool) -> None:
167
+ """Log a non-coding-CLI executor's post-verification outcome (verified vs blocked),
168
+ so standalone ``dev run`` has the same flow-decision trail as ``dev go``."""
169
+ if verified:
170
+ logger.info("%s finished and %s verified", executor, task_id)
171
+ else:
172
+ logger.warning("%s finished but %s blocked by verification gaps", executor, task_id)
173
+
174
+
84
175
  def _record_agent_verification(project_root: Path, task_id: str, executor: str, run_id: str | None, verified: bool) -> None:
85
176
  TraceLogger(project_root).log_event(
86
177
  "agent_run_verified",
@@ -114,6 +205,9 @@ def run(
114
205
  Execute a specific task.
115
206
  """
116
207
  root = project_root.expanduser().resolve()
208
+ from devcouncil.telemetry.logging_setup import set_log_dir
209
+ set_log_dir(root)
210
+ logger.info("dev run: task=%s executor=%s profile=%s stream=%s", task_id, executor, profile, stream)
117
211
  initialize_project(root, quiet=True)
118
212
  db = get_db(root)
119
213
  if not db:
@@ -131,6 +225,10 @@ def run(
131
225
  gate_policy = GatePolicy()
132
226
  gate_result = gate_policy.check_task_ready(task, root)
133
227
  if not gate_result.passed:
228
+ logger.warning(
229
+ "Task %s failed readiness gate: %s",
230
+ task_id, "; ".join(g.description for g in gate_result.gaps if g.blocking),
231
+ )
134
232
  console.print(f"[red]Task {task_id} is not ready for execution.[/red]")
135
233
  for gap in gate_result.gaps:
136
234
  if gap.blocking:
@@ -152,7 +250,8 @@ def run(
152
250
  console.print(f"[yellow]Warning: Failed to create git checkpoint: {e}[/yellow]")
153
251
 
154
252
  executor = executor.strip().lower().replace("_", "-")
155
- if executor not in CODING_EXECUTORS and executor not in _custom_cli_agents(root):
253
+ custom_agents = _custom_cli_agents(root)
254
+ if executor not in CODING_EXECUTORS and executor not in custom_agents:
156
255
  ignored = [flag for flag, value in (("--profile", profile), ("--stream", stream)) if value]
157
256
  if ignored:
158
257
  console.print(
@@ -162,10 +261,11 @@ def run(
162
261
  _record_project_phase(session, ProjectPhase.TASK_EXECUTING)
163
262
  task.status = "running"
164
263
  task_repo.save(task)
264
+ logger.info("%s marked RUNNING for manual sidecar execution", task_id)
165
265
  console.print(f"\n[green]Task {task_id} is now marked as RUNNING.[/green]")
166
266
  console.print("Use 'dev prompt TASK-ID' to get the prompt for this task.")
167
267
  console.print("When finished, use 'dev verify TASK-ID' to check the results.")
168
- elif executor in CODING_EXECUTORS or executor in _custom_cli_agents(root):
268
+ elif executor in CODING_EXECUTORS or executor in custom_agents:
169
269
  _record_project_phase(session, ProjectPhase.TASK_EXECUTING)
170
270
  req_repo = RequirementRepository(session)
171
271
  reqs = req_repo.get_all()
@@ -175,7 +275,9 @@ def run(
175
275
  _capture_after_patch(task_id, root)
176
276
  if exec_result.success:
177
277
  _record_project_phase(session, ProjectPhase.TASK_VERIFYING)
178
- verified = _verify_after_execution(session, task, reqs, project_root=root)
278
+ verified = _verify_after_execution(
279
+ session, task, reqs, router=_build_verification_router(root), project_root=root
280
+ )
179
281
  _record_agent_verification(root, task.id, cli_client, getattr(cli_executor, "last_run_id", None), verified)
180
282
  _record_project_phase(
181
283
  session,
@@ -183,17 +285,24 @@ def run(
183
285
  )
184
286
  task_repo.save(task)
185
287
  run_id = getattr(cli_executor, "last_run_id", None)
288
+ transcript_path = getattr(cli_executor, "last_transcript_path", None)
186
289
  if run_id:
187
290
  run_dir = root / ".devcouncil" / "runs" / run_id
188
291
  console.print(f"Run artifacts: [dim]{run_dir}[/dim]")
189
- transcript_path = getattr(cli_executor, "last_transcript_path", None)
292
+ if (run_dir / "run.log").exists():
293
+ console.print(f"Run log: [dim]dev logs tail --run {run_id}[/dim]")
190
294
  if transcript_path:
191
295
  console.print(f"Transcript: [dim]{transcript_path}[/dim]")
296
+ # Live review (advisory): critique the agent's turn with the live_reviewer
297
+ # role so monitoring actually happens during execution, not only via watch.
298
+ _run_live_review_after_execution(root, cli_client, task.id)
299
+ _log_exec_outcome(executor, task_id, verified=verified)
192
300
  if verified:
193
301
  console.print(f"\n[green]{executor.upper()} finished and task {task_id} verified.[/green]")
194
302
  else:
195
303
  console.print(f"\n[yellow]{executor.upper()} finished, but task {task_id} is blocked by verification gaps.[/yellow]")
196
304
  else:
305
+ logger.error("%s failed to start or execute for %s: %s", executor, task_id, exec_result.message)
197
306
  console.print(f"\n[red]{executor.upper()} failed to start or execute: {exec_result.message}[/red]")
198
307
  elif executor == "mini":
199
308
  _record_project_phase(session, ProjectPhase.TASK_EXECUTING)
@@ -204,17 +313,21 @@ def run(
204
313
  _capture_after_patch(task_id, root)
205
314
  if exec_result.success:
206
315
  _record_project_phase(session, ProjectPhase.TASK_VERIFYING)
207
- verified = _verify_after_execution(session, task, reqs, project_root=root)
316
+ verified = _verify_after_execution(
317
+ session, task, reqs, router=_build_verification_router(root), project_root=root
318
+ )
208
319
  _record_project_phase(
209
320
  session,
210
321
  ProjectPhase.TASK_VERIFIED if verified else ProjectPhase.TASK_BLOCKED,
211
322
  )
212
323
  task_repo.save(task)
324
+ _log_exec_outcome("mini-SWE-agent", task_id, verified=verified)
213
325
  if verified:
214
326
  console.print(f"\n[green]mini-SWE-agent finished and task {task_id} verified.[/green]")
215
327
  else:
216
328
  console.print(f"\n[yellow]mini-SWE-agent finished, but task {task_id} is blocked by verification gaps.[/yellow]")
217
329
  else:
330
+ logger.error("mini-SWE-agent failed to start or execute for %s", task_id)
218
331
  console.print("\n[red]mini-SWE-agent failed to start or execute.[/red]")
219
332
  elif executor == "openhands":
220
333
  _record_project_phase(session, ProjectPhase.TASK_EXECUTING)
@@ -225,17 +338,21 @@ def run(
225
338
  _capture_after_patch(task_id, root)
226
339
  if exec_result.success:
227
340
  _record_project_phase(session, ProjectPhase.TASK_VERIFYING)
228
- verified = _verify_after_execution(session, task, reqs, project_root=root)
341
+ verified = _verify_after_execution(
342
+ session, task, reqs, router=_build_verification_router(root), project_root=root
343
+ )
229
344
  _record_project_phase(
230
345
  session,
231
346
  ProjectPhase.TASK_VERIFIED if verified else ProjectPhase.TASK_BLOCKED,
232
347
  )
233
348
  task_repo.save(task)
349
+ _log_exec_outcome("OpenHands", task_id, verified=verified)
234
350
  if verified:
235
351
  console.print(f"\n[green]OpenHands finished and task {task_id} verified.[/green]")
236
352
  else:
237
353
  console.print(f"\n[yellow]OpenHands finished, but task {task_id} is blocked by verification gaps.[/yellow]")
238
354
  else:
355
+ logger.error("OpenHands failed to start or execute for %s", task_id)
239
356
  console.print("\n[red]OpenHands failed to start or execute.[/red]")
240
357
  elif executor in {"native", "native-preview"}:
241
358
  # Load config for model routing and permissions
@@ -244,10 +361,11 @@ def run(
244
361
  validate_model_provider(config.models.provider)
245
362
  api_key = get_api_key(config.models.provider, root)
246
363
  except (FileNotFoundError, ValueError) as e:
364
+ logger.error("Native executor cannot start for %s: %s", task_id, e)
247
365
  console.print(f"[red]{e}[/red]")
248
366
  return
249
367
 
250
- provider = create_provider(config.models.provider, api_key, project_root=root)
368
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
251
369
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
252
370
  router = ModelRouter(provider, role_config, project_root=root)
253
371
 
@@ -279,11 +397,14 @@ def run(
279
397
  ProjectPhase.TASK_VERIFIED if verified else ProjectPhase.TASK_BLOCKED,
280
398
  )
281
399
  task_repo.save(task)
400
+ _log_exec_outcome("Native agent", task_id, verified=verified)
282
401
  if verified:
283
402
  console.print(f"\n[green]Native agent finished and task {task_id} verified.[/green]")
284
403
  else:
285
404
  console.print(f"\n[yellow]Native agent finished, but task {task_id} is blocked by verification gaps.[/yellow]")
286
405
  else:
406
+ logger.error("Native agent failed during execution for %s", task_id)
287
407
  console.print("\n[red]Native agent failed during execution.[/red]")
288
408
  else:
409
+ logger.error("Executor %r not implemented", executor)
289
410
  console.print(f"[red]Executor {executor} not yet implemented.[/red]")
@@ -1,10 +1,12 @@
1
+ import asyncio
1
2
  from pathlib import Path
2
3
 
3
4
  import typer
4
5
  from rich.console import Console
5
6
  from rich.table import Table
6
7
 
7
- from devcouncil.skills.registry import get_skill, load_skills, scaffold_skills, select_skills
8
+ from devcouncil.knowledge.frontmatter import build_frontmatter_markdown
9
+ from devcouncil.skills.registry import Skill, get_skill, load_skills, scaffold_skills, select_skills
8
10
 
9
11
  app = typer.Typer(help="Inspect and scaffold DevCouncil engineering skills for coding agents.")
10
12
  console = Console()
@@ -86,3 +88,180 @@ def scaffold(
86
88
  console.print(f"[green]Wrote {len(written)} skill file(s):[/green]")
87
89
  for path in written:
88
90
  console.print(f" {path.relative_to(root).as_posix()}")
91
+
92
+
93
+ def _skill_to_markdown(skill: Skill, body: str) -> str:
94
+ """Render a skill back to markdown, preserving its selection frontmatter."""
95
+ meta: dict[str, object] = {"name": skill.name}
96
+ if skill.title:
97
+ meta["title"] = skill.title
98
+ if skill.description:
99
+ meta["description"] = skill.description
100
+ if skill.always:
101
+ meta["always"] = True
102
+ triggers = {
103
+ k: v
104
+ for k, v in {"keywords": skill.triggers.keywords, "globs": skill.triggers.globs}.items()
105
+ if v
106
+ }
107
+ if triggers:
108
+ meta["triggers"] = triggers
109
+ return build_frontmatter_markdown(meta, body)
110
+
111
+
112
+ def _write_skill_body(project_root: Path, skill: Skill, body: str) -> Path:
113
+ """Persist an optimized skill body, overwriting a repo-local skill in place or
114
+ materializing a packaged-library skill under ``.devcouncil/skills/<name>.md``."""
115
+ content = _skill_to_markdown(skill, body)
116
+ if skill.source_path is not None:
117
+ try:
118
+ skill.source_path.resolve().relative_to(project_root.resolve())
119
+ skill.source_path.write_text(content, encoding="utf-8")
120
+ return skill.source_path
121
+ except ValueError:
122
+ pass
123
+ target = project_root / ".devcouncil" / "skills" / f"{skill.name}.md"
124
+ target.parent.mkdir(parents=True, exist_ok=True)
125
+ target.write_text(content, encoding="utf-8")
126
+ return target
127
+
128
+
129
+ def _build_router(project_root: Path):
130
+ """Build a ModelRouter from project config, adding SkillOpt roles when absent."""
131
+ from devcouncil.app.config import get_api_key, load_config
132
+ from devcouncil.llm.provider import create_provider
133
+ from devcouncil.llm.router import ModelRouter
134
+
135
+ config = load_config(project_root)
136
+ api_key = get_api_key(config.models.provider, project_root)
137
+ provider = create_provider(config.models.provider, api_key, project_root=project_root, provider_prefs=config.provider)
138
+ role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
139
+ if not role_config:
140
+ raise RuntimeError(
141
+ "No model roles configured in .devcouncil/config.yaml. "
142
+ "Run 'dev init' or add a 'models.roles' entry before optimizing."
143
+ )
144
+ # SkillOpt's rollout/optimizer roles fall back to a capable existing role when the
145
+ # project config doesn't define dedicated ones. Copy the dict so the three roles
146
+ # don't alias one config object.
147
+ capable = role_config.get("arbiter") or role_config.get("planner_a") or next(iter(role_config.values()))
148
+ role_config.setdefault("skill_target", dict(capable))
149
+ role_config.setdefault("skill_optimizer", dict(capable))
150
+ return ModelRouter(provider, role_config, project_root=project_root)
151
+
152
+
153
+ @app.command("optimize")
154
+ def optimize(
155
+ name: str = typer.Argument(..., help="Skill name to optimize, e.g. core-engineering."),
156
+ evals_path: Path = typer.Option(..., "--evals", help="JSON or JSONL dataset of evaluation tasks."),
157
+ profile_name: str = typer.Option(
158
+ "default", "--profile", help="Agent profile whose prompt preamble (guidance) is co-optimized."
159
+ ),
160
+ epochs: int = typer.Option(5, "--epochs", min=1, help="Optimization epochs."),
161
+ max_edits: int = typer.Option(3, "--max-edits", min=1, help="Edit budget per epoch (textual learning rate)."),
162
+ val_fraction: float = typer.Option(0.5, "--val-fraction", min=0.0, max=1.0, help="Held-out validation fraction."),
163
+ seed: int = typer.Option(0, "--seed", help="Seed for the deterministic train/validation split."),
164
+ apply: bool = typer.Option(
165
+ False,
166
+ "--apply/--dry-run",
167
+ help="Write the optimized skill body and guidance preamble back to disk. Defaults to dry-run.",
168
+ ),
169
+ output_path: Path | None = typer.Option(None, "--output", help="Write the optimization artifact to this path."),
170
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
171
+ ):
172
+ """Co-optimize a skill document and its agent guidance preamble with the SkillOpt loop.
173
+
174
+ Each epoch runs the skill+guidance on training tasks, scores the rollouts, and lets an
175
+ optimizer model propose bounded edits to **both** documents at once; a candidate is kept
176
+ only if it strictly improves the held-out validation score.
177
+ """
178
+ from devcouncil.executors.agent_registry import load_agent_profiles
179
+ from devcouncil.optimization.gepa_agent import load_agent_eval_dataset
180
+ from devcouncil.optimization.skillopt import (
181
+ GUIDANCE,
182
+ SKILL,
183
+ DEFAULT_OBJECTIVE,
184
+ SkillOptConfig,
185
+ default_artifact_path,
186
+ make_llm_optimizer,
187
+ make_llm_rollout,
188
+ optimize_skill,
189
+ write_result_artifact,
190
+ )
191
+ from devcouncil.optimization.gepa_agent import _apply_profile_preamble
192
+
193
+ root = project_root.expanduser().resolve()
194
+ skill = get_skill(name, project_root=root)
195
+ if skill is None:
196
+ console.print(f"[red]No skill named '{name}'. Run 'dev skills' to list available skills.[/red]")
197
+ raise typer.Exit(code=1)
198
+
199
+ resolved_evals = evals_path.expanduser()
200
+ if not resolved_evals.is_absolute():
201
+ resolved_evals = root / resolved_evals
202
+ try:
203
+ dataset = load_agent_eval_dataset(resolved_evals)
204
+ except ValueError as exc:
205
+ console.print(f"[red]{exc}[/red]")
206
+ raise typer.Exit(code=2) from exc
207
+
208
+ profiles = load_agent_profiles(root)
209
+ profile = profiles.get(profile_name)
210
+ if profile is None:
211
+ known = ", ".join(sorted(profiles)) or "(none)"
212
+ console.print(
213
+ f"[red]No agent profile named '{profile_name}'. Known profiles: {known}.[/red]"
214
+ )
215
+ raise typer.Exit(code=2)
216
+ guidance = profile.prompt_preamble or ""
217
+
218
+ try:
219
+ router = _build_router(root)
220
+ except (RuntimeError, ValueError, FileNotFoundError) as exc:
221
+ console.print(f"[red]{exc}[/red]")
222
+ raise typer.Exit(code=1) from exc
223
+
224
+ rollout = make_llm_rollout(router)
225
+ optimizer = make_llm_optimizer(router)
226
+ result = asyncio.run(
227
+ optimize_skill(
228
+ skill_name=skill.name,
229
+ docs={GUIDANCE: guidance, SKILL: skill.body},
230
+ dataset=dataset,
231
+ rollout=rollout,
232
+ optimizer=optimizer,
233
+ config=SkillOptConfig(
234
+ epochs=epochs, max_edits_per_epoch=max_edits, val_fraction=val_fraction, seed=seed
235
+ ),
236
+ )
237
+ )
238
+
239
+ artifact_path = (output_path or default_artifact_path(root, skill.name)).expanduser()
240
+ if not artifact_path.is_absolute():
241
+ artifact_path = root / artifact_path
242
+ result.artifact_path = artifact_path
243
+ result.applied = apply
244
+ write_result_artifact(
245
+ artifact_path, result, objective=DEFAULT_OBJECTIVE, dataset_path=str(resolved_evals)
246
+ )
247
+
248
+ if apply and result.improved:
249
+ # Only write a document that actually changed, so a guidance-only improvement
250
+ # doesn't churn the skill file (and vice versa).
251
+ if result.best_skill_body != skill.body:
252
+ skill_path = _write_skill_body(root, skill, result.best_skill_body)
253
+ console.print(f"[green]Updated skill body:[/green] {skill_path.relative_to(root).as_posix()}")
254
+ if result.best_guidance_body != guidance:
255
+ _apply_profile_preamble(root, profile_name, result.best_guidance_body)
256
+ console.print(f"[green]Updated guidance preamble for profile '{profile_name}'.[/green]")
257
+
258
+ mode = "applied" if (apply and result.improved) else "dry-run"
259
+ console.print(
260
+ f"[green]SkillOpt complete ({mode}) for '{skill.name}'.[/green] "
261
+ f"validation {result.seed_val_score:.3f} -> {result.best_val_score:.3f} "
262
+ f"over {len(result.epochs)} epoch(s), "
263
+ f"{result.accepted_edit_count} edit(s) accepted, {result.rejected_edit_count} rejected."
264
+ )
265
+ console.print(f"Artifact: [dim]{artifact_path}[/dim]")
266
+ if apply and not result.improved:
267
+ console.print("[yellow]No validated improvement — nothing written. Re-run with more epochs or data.[/yellow]")
@@ -8,7 +8,7 @@ from devcouncil.cli.commands.init import initialize_project
8
8
  from devcouncil.app.project_status import compute_phase
9
9
  from devcouncil.storage.db import get_db
10
10
  from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
11
- from devcouncil.telemetry.cost import CostEstimator, cost_by_task
11
+ from devcouncil.telemetry.cost import group_cost
12
12
  from devcouncil.live.summary import live_review_summary
13
13
 
14
14
  console = Console()
@@ -28,19 +28,10 @@ def _status_payload(project_root: Path) -> dict:
28
28
  state = StateRepository(session).get_state()
29
29
  phase = compute_phase(graph, state.current_phase if state else None)
30
30
 
31
- total_cost = 0.0
32
- log_file = project_root / ".devcouncil" / "logs" / "model_calls.jsonl"
33
- if log_file.exists():
34
- with open(log_file, "r", encoding="utf-8") as f:
35
- for line in f:
36
- try:
37
- entry = json.loads(line)
38
- total_cost += CostEstimator.estimate_cost(
39
- entry.get("response", {}).get("model", ""),
40
- entry.get("usage", {}),
41
- )
42
- except Exception:
43
- continue
31
+ # Single read of the model-call ledger: derive both the grand total and the
32
+ # per-task breakdown from one pass (group_cost -> read_cost_records). This is
33
+ # provider-aware (ollama records are free), matching the Cost-by-Task table.
34
+ cost = group_cost(project_root)
44
35
 
45
36
  status_counts: dict[str, int] = {}
46
37
  for task in graph.tasks.values():
@@ -50,8 +41,8 @@ def _status_payload(project_root: Path) -> dict:
50
41
  "initialized": True,
51
42
  "phase": phase,
52
43
  "coverage_summary": summary,
53
- "total_cost": total_cost,
54
- "cost_by_task": cost_by_task(project_root),
44
+ "total_cost": cost["total_cost"],
45
+ "cost_by_task": cost["by_task"],
55
46
  "task_status_counts": status_counts,
56
47
  "blocking_gaps": [gap.model_dump() for gap in blocking_gaps],
57
48
  "live_review": live_review_summary(project_root),
@@ -1,6 +1,7 @@
1
1
  import typer
2
2
  import asyncio
3
3
  import json
4
+ import logging
4
5
  from rich.console import Console
5
6
  from rich.table import Table
6
7
  from pathlib import Path
@@ -20,6 +21,7 @@ from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
20
21
  from devcouncil.telemetry.traces import TraceLogger
21
22
 
22
23
  console = Console()
24
+ logger = logging.getLogger(__name__)
23
25
  MAX_RENDERED_GAPS = 20
24
26
 
25
27
 
@@ -55,6 +57,9 @@ def verify(
55
57
  Verify one task, or all tasks when TASK_ID is omitted.
56
58
  """
57
59
  root = project_root.expanduser().resolve()
60
+ from devcouncil.telemetry.logging_setup import set_log_dir
61
+ set_log_dir(root)
62
+ logger.info("dev verify: task=%s sandbox=%s", task_id or "ALL", sandbox)
58
63
  initialize_project(root, quiet=True)
59
64
  db = get_db(root)
60
65
  if not db:
@@ -88,7 +93,7 @@ def verify(
88
93
  config = load_config(root)
89
94
  validate_model_provider(config.models.provider)
90
95
  api_key = get_api_key(config.models.provider, root)
91
- provider = create_provider(config.models.provider, api_key, project_root=root)
96
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
92
97
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
93
98
  router = ModelRouter(provider, role_config, project_root=root)
94
99
  except Exception:
@@ -236,6 +241,7 @@ def verify(
236
241
  # against the current tree, so a regression would have failed the test and the AC
237
242
  # would not be in proven_acs — this clears only genuinely-satisfied criteria.
238
243
  if task_id is None and proven_acs:
244
+ result_map = {r["task_id"]: r for r in task_results}
239
245
  for task in tasks:
240
246
  gaps = per_task_gaps.get(task.id, [])
241
247
  kept = reconcile_cross_task_acceptance(gaps, proven_acs)
@@ -255,15 +261,15 @@ def verify(
255
261
  summary=f"{task.id} verified via cross-task acceptance reconciliation",
256
262
  )
257
263
  task_repo.save(task)
258
- for result in task_results:
259
- if result["task_id"] == task.id:
260
- blocking_actions, advisory_actions = split_next_actions(kept)
261
- result["status"] = task.status
262
- result["gap_count"] = len(kept)
263
- result["blocking_gap_count"] = len([gap for gap in kept if gap.blocking])
264
- result["gaps"] = [gap.model_dump() for gap in kept]
265
- result["next_actions"] = [action.model_dump() for action in blocking_actions]
266
- result["advisory_actions"] = [action.model_dump() for action in advisory_actions]
264
+ if task.id in result_map:
265
+ result = result_map[task.id]
266
+ blocking_actions, advisory_actions = split_next_actions(kept)
267
+ result["status"] = task.status
268
+ result["gap_count"] = len(kept)
269
+ result["blocking_gap_count"] = len([gap for gap in kept if gap.blocking])
270
+ result["gaps"] = [gap.model_dump() for gap in kept]
271
+ result["next_actions"] = [action.model_dump() for action in blocking_actions]
272
+ result["advisory_actions"] = [action.model_dump() for action in advisory_actions]
267
273
 
268
274
  StateRepository(session).record_phase(
269
275
  ProjectPhase.TASK_BLOCKED.value if blocked_tasks else ProjectPhase.TASK_VERIFIED.value