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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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":
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|