devcouncil 0.1.1 → 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 +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- 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 +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- 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 +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -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/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from devcouncil.cli.commands import run as run_command
|
|
12
|
+
from devcouncil.cli.commands.integrate import _load_raw_config, _project_root, _save_raw_config
|
|
13
|
+
from devcouncil.executors.agent_registry import (
|
|
14
|
+
VALID_INPUT_MODES,
|
|
15
|
+
agent_config_entry,
|
|
16
|
+
detect_available_coding_cli,
|
|
17
|
+
is_reserved_agent_name,
|
|
18
|
+
load_agent_profiles,
|
|
19
|
+
load_cli_agent_specs,
|
|
20
|
+
normalize_agent_name,
|
|
21
|
+
resolve_automated_executor,
|
|
22
|
+
resolve_cursor_agent_executable,
|
|
23
|
+
)
|
|
24
|
+
from devcouncil.optimization.gepa_agent import GepaUnavailableError, optimize_agent_profile
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(help="Manage DevCouncil CLI agents.")
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback(invoke_without_command=True)
|
|
31
|
+
def list_agents(
|
|
32
|
+
ctx: typer.Context,
|
|
33
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
34
|
+
):
|
|
35
|
+
"""List built-in and configured CLI agents."""
|
|
36
|
+
if ctx.invoked_subcommand is not None:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
root = _project_root(project_root)
|
|
40
|
+
table = Table(title="DevCouncil Agents")
|
|
41
|
+
table.add_column("Agent", style="cyan")
|
|
42
|
+
table.add_column("Type")
|
|
43
|
+
table.add_column("Command")
|
|
44
|
+
table.add_column("Profile")
|
|
45
|
+
table.add_column("MCP")
|
|
46
|
+
table.add_column("Diff Review")
|
|
47
|
+
|
|
48
|
+
for name, spec in sorted(load_cli_agent_specs(root).items()):
|
|
49
|
+
table.add_row(
|
|
50
|
+
name,
|
|
51
|
+
"built-in" if spec.built_in else spec.kind,
|
|
52
|
+
" ".join(spec.base_command()),
|
|
53
|
+
spec.default_profile,
|
|
54
|
+
"yes" if spec.supports_mcp else "no",
|
|
55
|
+
"yes" if spec.supports_diff_review else "no",
|
|
56
|
+
)
|
|
57
|
+
console.print(table)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command("add")
|
|
61
|
+
def add_agent(
|
|
62
|
+
name: str = typer.Argument(..., help="Agent name, for example opencode or aider."),
|
|
63
|
+
command: str = typer.Option(..., "--command", help="Executable to launch."),
|
|
64
|
+
arg: list[str] | None = typer.Option(None, "--arg", help="Argument to pass to the CLI. Repeat for multiple args."),
|
|
65
|
+
input_mode: str = typer.Option("stdin", "--input-mode", help="Prompt input mode: stdin, argument, or prompt-file."),
|
|
66
|
+
prompt_arg: str | None = typer.Option(None, "--prompt-arg", help="Flag used before the prompt or prompt file."),
|
|
67
|
+
timeout_seconds: int | None = typer.Option(None, "--timeout-seconds", help="Agent-specific timeout override."),
|
|
68
|
+
display_name: str | None = typer.Option(None, "--display-name", help="Human-readable agent name."),
|
|
69
|
+
kind: str = typer.Option("custom", "--kind", help="Agent kind, for example coding-cli or review-cli."),
|
|
70
|
+
supports_mcp: bool = typer.Option(False, "--supports-mcp", help="Mark this agent as MCP-capable."),
|
|
71
|
+
supports_diff_review: bool = typer.Option(False, "--supports-diff-review", help="Mark this agent as able to review diffs."),
|
|
72
|
+
default_profile: str = typer.Option("default", "--default-profile", help="Default execution profile for this agent."),
|
|
73
|
+
help_arg: list[str] | None = typer.Option(None, "--help-arg", help="Argument for the agent help command. Repeat for multiple args."),
|
|
74
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
75
|
+
):
|
|
76
|
+
"""Register an arbitrary prompt-taking CLI as a DevCouncil agent."""
|
|
77
|
+
if input_mode not in VALID_INPUT_MODES:
|
|
78
|
+
console.print("[red]--input-mode must be one of: stdin, argument, prompt-file.[/red]")
|
|
79
|
+
raise typer.Exit(code=2)
|
|
80
|
+
if not name.strip():
|
|
81
|
+
console.print("[red]Agent name cannot be empty.[/red]")
|
|
82
|
+
raise typer.Exit(code=2)
|
|
83
|
+
if not command.strip():
|
|
84
|
+
console.print("[red]--command cannot be empty.[/red]")
|
|
85
|
+
raise typer.Exit(code=2)
|
|
86
|
+
|
|
87
|
+
root = _project_root(project_root)
|
|
88
|
+
if is_reserved_agent_name(name):
|
|
89
|
+
console.print(f"[red]'{name}' is reserved for a built-in DevCouncil agent.[/red]")
|
|
90
|
+
raise typer.Exit(code=2)
|
|
91
|
+
if default_profile not in load_agent_profiles(root):
|
|
92
|
+
console.print(f"[red]Unknown --default-profile '{default_profile}'.[/red]")
|
|
93
|
+
raise typer.Exit(code=2)
|
|
94
|
+
|
|
95
|
+
normalized = normalize_agent_name(name)
|
|
96
|
+
entry = agent_config_entry(
|
|
97
|
+
command=command,
|
|
98
|
+
args=arg or [],
|
|
99
|
+
input_mode=input_mode,
|
|
100
|
+
prompt_arg=prompt_arg,
|
|
101
|
+
timeout_seconds=timeout_seconds,
|
|
102
|
+
display_name=display_name,
|
|
103
|
+
kind=kind,
|
|
104
|
+
supports_mcp=supports_mcp,
|
|
105
|
+
supports_diff_review=supports_diff_review,
|
|
106
|
+
default_profile=default_profile,
|
|
107
|
+
help_command=[command, *(help_arg or [])] if help_arg else [],
|
|
108
|
+
)
|
|
109
|
+
config = _load_raw_config(root)
|
|
110
|
+
agents = config.setdefault("integrations", {}).setdefault("cli_agents", {}).setdefault("agents", {})
|
|
111
|
+
agents[normalized] = entry
|
|
112
|
+
_save_raw_config(root, config)
|
|
113
|
+
console.print(f"[green]Registered CLI agent '{normalized}' in .devcouncil/config.yaml.[/green]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("doctor")
|
|
117
|
+
def doctor(
|
|
118
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
119
|
+
):
|
|
120
|
+
"""Check configured CLI agents and execution profiles."""
|
|
121
|
+
root = _project_root(project_root)
|
|
122
|
+
profiles = load_agent_profiles(root)
|
|
123
|
+
table = Table(title="DevCouncil Agent Doctor")
|
|
124
|
+
table.add_column("Agent", style="cyan")
|
|
125
|
+
table.add_column("Status")
|
|
126
|
+
table.add_column("Details", no_wrap=True)
|
|
127
|
+
|
|
128
|
+
for name, spec in sorted(load_cli_agent_specs(root).items()):
|
|
129
|
+
if spec.name == "cursor":
|
|
130
|
+
executable = resolve_cursor_agent_executable()
|
|
131
|
+
else:
|
|
132
|
+
executable = _which(spec.executable)
|
|
133
|
+
mode_ok = spec.input_mode in VALID_INPUT_MODES
|
|
134
|
+
profile_ok = spec.default_profile in profiles
|
|
135
|
+
help_ok, help_detail = _check_help(spec.help_command or [spec.executable, "--help"])
|
|
136
|
+
|
|
137
|
+
if executable and mode_ok and profile_ok:
|
|
138
|
+
status = "[green]OK[/green]"
|
|
139
|
+
elif executable:
|
|
140
|
+
status = "[red]Invalid[/red]"
|
|
141
|
+
else:
|
|
142
|
+
status = "[yellow]Missing[/yellow]"
|
|
143
|
+
|
|
144
|
+
details = []
|
|
145
|
+
details.append(executable or f"{spec.executable} not found on PATH")
|
|
146
|
+
if not mode_ok:
|
|
147
|
+
details.append(f"invalid input_mode={spec.input_mode}")
|
|
148
|
+
if not profile_ok:
|
|
149
|
+
details.append(f"missing profile={spec.default_profile}")
|
|
150
|
+
if help_ok:
|
|
151
|
+
details.append("help command OK")
|
|
152
|
+
elif not spec.built_in:
|
|
153
|
+
details.append(help_detail)
|
|
154
|
+
table.add_row(name, status, "; ".join(details))
|
|
155
|
+
|
|
156
|
+
console.print(table)
|
|
157
|
+
detected = detect_available_coding_cli(root)
|
|
158
|
+
if detected:
|
|
159
|
+
resolved = resolve_automated_executor(root, None)
|
|
160
|
+
console.print(
|
|
161
|
+
f"\n[dim]Auto-pick for dev go / dev run:[/dim] [cyan]{resolved}[/cyan] "
|
|
162
|
+
f"(first built-in CLI on PATH in probe order)"
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
console.print("\n[dim]No built-in coding CLI on PATH for auto-pick.[/dim]")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command("run")
|
|
169
|
+
def run_agent(
|
|
170
|
+
task_id: str = typer.Argument(..., help="ID of the task to run."),
|
|
171
|
+
agent: str = typer.Option(..., "--agent", "-a", help="Agent name to execute."),
|
|
172
|
+
profile: str | None = typer.Option(None, "--profile", help="Execution profile: default, yolo, prod, or configured."),
|
|
173
|
+
stream: bool = typer.Option(
|
|
174
|
+
False,
|
|
175
|
+
"--stream",
|
|
176
|
+
help="Stream coding CLI stdout/stderr live (also enabled by execution.stream_cli_output).",
|
|
177
|
+
),
|
|
178
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
179
|
+
):
|
|
180
|
+
"""Run a DevCouncil task with a named CLI agent and profile."""
|
|
181
|
+
run_command.run(task_id, executor=agent, profile=profile, stream=stream, project_root=project_root)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _which(command: str) -> str | None:
|
|
185
|
+
from shutil import which
|
|
186
|
+
|
|
187
|
+
return which(command)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command("help")
|
|
191
|
+
def agent_help(
|
|
192
|
+
name: str = typer.Argument(..., help="Agent name, for example codex or opencode."),
|
|
193
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
194
|
+
):
|
|
195
|
+
"""Show the underlying CLI agent's own help output."""
|
|
196
|
+
root = _project_root(project_root)
|
|
197
|
+
spec = load_cli_agent_specs(root).get(normalize_agent_name(name))
|
|
198
|
+
if spec is None:
|
|
199
|
+
console.print(f"[red]Unknown agent '{name}'. Use 'dev agents' to list available agents.[/red]")
|
|
200
|
+
raise typer.Exit(code=1)
|
|
201
|
+
command = list(spec.help_command) or [spec.executable, "--help"]
|
|
202
|
+
executable = _which(command[0])
|
|
203
|
+
if not executable:
|
|
204
|
+
console.print(f"[red]{command[0]} is not installed or not on PATH.[/red]")
|
|
205
|
+
raise typer.Exit(code=1)
|
|
206
|
+
resolved = [executable, *command[1:]]
|
|
207
|
+
use_shell = sys.platform == "win32" and Path(executable).suffix.lower() in {".bat", ".cmd", ".ps1"}
|
|
208
|
+
try:
|
|
209
|
+
result = subprocess.run(
|
|
210
|
+
subprocess.list2cmdline(resolved) if use_shell else resolved,
|
|
211
|
+
capture_output=True,
|
|
212
|
+
text=True,
|
|
213
|
+
encoding="utf-8",
|
|
214
|
+
errors="replace",
|
|
215
|
+
timeout=15,
|
|
216
|
+
shell=use_shell,
|
|
217
|
+
)
|
|
218
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
219
|
+
console.print(f"[red]Failed to run {' '.join(command)}: {exc}[/red]")
|
|
220
|
+
raise typer.Exit(code=1)
|
|
221
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
222
|
+
typer.echo(output.strip())
|
|
223
|
+
raise typer.Exit(code=result.returncode)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _check_help(command: list[str]) -> tuple[bool, str]:
|
|
227
|
+
executable = _which(command[0]) if command else None
|
|
228
|
+
if not command or not executable:
|
|
229
|
+
return False, "help command unavailable"
|
|
230
|
+
resolved = [executable, *command[1:]]
|
|
231
|
+
use_shell = sys.platform == "win32" and Path(executable).suffix.lower() in {".bat", ".cmd", ".ps1"}
|
|
232
|
+
try:
|
|
233
|
+
result = subprocess.run(
|
|
234
|
+
subprocess.list2cmdline(resolved) if use_shell else resolved,
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
encoding="utf-8",
|
|
238
|
+
errors="replace",
|
|
239
|
+
timeout=5,
|
|
240
|
+
shell=use_shell,
|
|
241
|
+
)
|
|
242
|
+
except subprocess.TimeoutExpired:
|
|
243
|
+
return False, "help command timed out"
|
|
244
|
+
return result.returncode == 0, f"help command exited {result.returncode}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command("optimize")
|
|
248
|
+
def optimize_agent(
|
|
249
|
+
agent: str = typer.Option(..., "--agent", "-a", help="Agent name to optimize, for example codex or opencode."),
|
|
250
|
+
profile_name: str = typer.Option("default", "--profile", help="Profile name to optimize: default, yolo, prod, or configured."),
|
|
251
|
+
evals_path: Path = typer.Option(..., "--evals", help="JSON or JSONL GEPA evaluation dataset."),
|
|
252
|
+
max_metric_calls: int = typer.Option(40, "--max-metric-calls", min=1, help="Maximum GEPA metric calls."),
|
|
253
|
+
objective: str | None = typer.Option(None, "--objective", help="Override the default optimization objective."),
|
|
254
|
+
apply: bool = typer.Option(
|
|
255
|
+
False,
|
|
256
|
+
"--apply/--dry-run",
|
|
257
|
+
help="Write the optimized preamble into .devcouncil/config.yaml. Defaults to dry-run.",
|
|
258
|
+
),
|
|
259
|
+
output_path: Path | None = typer.Option(None, "--output", help="Write the optimization artifact to this path."),
|
|
260
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
261
|
+
):
|
|
262
|
+
"""
|
|
263
|
+
Optimize a CLI-agent profile prompt preamble with GEPA.
|
|
264
|
+
"""
|
|
265
|
+
root = _project_root(project_root)
|
|
266
|
+
try:
|
|
267
|
+
result = optimize_agent_profile(
|
|
268
|
+
project_root=root,
|
|
269
|
+
agent=agent,
|
|
270
|
+
profile_name=profile_name,
|
|
271
|
+
evals_path=evals_path,
|
|
272
|
+
max_metric_calls=max_metric_calls,
|
|
273
|
+
objective=objective,
|
|
274
|
+
apply=apply,
|
|
275
|
+
output_path=output_path,
|
|
276
|
+
)
|
|
277
|
+
except GepaUnavailableError as exc:
|
|
278
|
+
console.print(f"[red]{exc}[/red]")
|
|
279
|
+
raise typer.Exit(code=1) from exc
|
|
280
|
+
except ValueError as exc:
|
|
281
|
+
console.print(f"[red]{exc}[/red]")
|
|
282
|
+
raise typer.Exit(code=2) from exc
|
|
283
|
+
|
|
284
|
+
mode = "applied" if result.applied else "dry-run"
|
|
285
|
+
score = "unknown" if result.best_score is None else f"{result.best_score:.3f}"
|
|
286
|
+
console.print(
|
|
287
|
+
f"[green]GEPA optimization complete ({mode}) for {result.agent}:{result.profile_name}.[/green]"
|
|
288
|
+
)
|
|
289
|
+
console.print(f"Score: [bold]{score}[/bold]")
|
|
290
|
+
console.print(f"Artifact: [dim]{result.artifact_path}[/dim]")
|
|
291
|
+
console.print("\n[bold]Optimized preamble[/bold]")
|
|
292
|
+
console.print(result.best_preamble or "[dim](empty)[/dim]")
|
|
@@ -14,10 +14,13 @@ console = Console()
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
@app.command(name="validate")
|
|
17
|
-
def validate(
|
|
17
|
+
def validate(
|
|
18
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
19
|
+
):
|
|
18
20
|
"""Validate requirements and tasks stored in the artifact graph."""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
root = project_root.expanduser().resolve()
|
|
22
|
+
initialize_project(root, quiet=True)
|
|
23
|
+
db = get_db(root)
|
|
21
24
|
if not db:
|
|
22
25
|
console.print("[red]DevCouncil state is unavailable in this directory.[/red]")
|
|
23
26
|
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""`dev check` — a one-shot audit of the current uncommitted changes.
|
|
2
|
+
|
|
3
|
+
The lowest-friction entry point: no planning, no task graph. You let a coding
|
|
4
|
+
agent change your repo, then `dev check` tells you what is out of scope, what
|
|
5
|
+
edge cases look unhandled, what's risky, and whether any secrets leaked —
|
|
6
|
+
grounded in the real diff. With ``--goal`` it also compiles acceptance checks
|
|
7
|
+
from the stated intent and runs them as evidence.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from devcouncil.app.config import get_api_key, load_config
|
|
23
|
+
from devcouncil.cli.commands.init import initialize_project
|
|
24
|
+
from devcouncil.domain.task import Task
|
|
25
|
+
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
26
|
+
from devcouncil.integrations.github_intent import resolve_goal_intent
|
|
27
|
+
from devcouncil.llm.provider import ProviderRequestError, create_provider, validate_model_provider
|
|
28
|
+
from devcouncil.llm.router import ModelRouter, StructuredOutputError
|
|
29
|
+
from devcouncil.verification.ad_hoc_check import AdHocCheckResult, run_working_tree_check
|
|
30
|
+
from devcouncil.verification.implementation_reviewer import ImplementationReviewer
|
|
31
|
+
from devcouncil.verification.verifier import Verifier
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _diff(root: Path, base: str | None) -> str:
|
|
38
|
+
if not base:
|
|
39
|
+
return Verifier(root).get_diff()
|
|
40
|
+
try:
|
|
41
|
+
return subprocess.check_output(
|
|
42
|
+
["git", "diff", base, "--"], cwd=root, text=True, encoding="utf-8", errors="replace"
|
|
43
|
+
)
|
|
44
|
+
except Exception:
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check(
|
|
49
|
+
goal: str | None = typer.Option(None, "--goal", "-g", help="What the change was meant to do — sharpens the review and enables acceptance checks. Also accepts a GitHub issue/PR reference (#142, owner/repo#142, or a github.com URL)."),
|
|
50
|
+
base: str | None = typer.Option(None, "--base", help="Diff against this git ref instead of the uncommitted working tree."),
|
|
51
|
+
test_commands: list[str] | None = typer.Option(None, "--test", "-t", help="A verification command proving the change works (repeatable). Switches to the deterministic evidence gate."),
|
|
52
|
+
verify: bool = typer.Option(False, "--verify", help="Run the deterministic evidence gate (orphan-diff, acceptance evidence, diff↔coverage, next actions) instead of the LLM audit. No provider keys needed."),
|
|
53
|
+
enforce_coverage: bool = typer.Option(False, "--enforce-coverage", help="Evidence gate: block when the tests do not exercise the changed lines."),
|
|
54
|
+
min_coverage: float = typer.Option(0.0, "--min-coverage", help="Evidence gate: minimum fraction of changed lines that must be exercised (implies --enforce-coverage)."),
|
|
55
|
+
json_format: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
56
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root."),
|
|
57
|
+
):
|
|
58
|
+
"""Audit the current changes — scope, risks, missing edge cases, secrets — no planning required."""
|
|
59
|
+
root = project_root.expanduser().resolve()
|
|
60
|
+
from devcouncil.telemetry.logging_setup import set_log_dir
|
|
61
|
+
set_log_dir(root)
|
|
62
|
+
initialize_project(root, quiet=True)
|
|
63
|
+
|
|
64
|
+
# A --goal of "#142" or a GitHub issue/PR URL is a reference, not a spec —
|
|
65
|
+
# expand it into the issue/PR title + body so acceptance checks and the review
|
|
66
|
+
# are grounded in the real intent (same behavior as `dev go`).
|
|
67
|
+
if goal:
|
|
68
|
+
expanded_goal, intent_note = resolve_goal_intent(goal, root)
|
|
69
|
+
if intent_note and not json_format:
|
|
70
|
+
console.print(f"[dim]{intent_note}[/dim]")
|
|
71
|
+
goal = expanded_goal
|
|
72
|
+
|
|
73
|
+
# Evidence-gate mode: deterministic verification of the working-tree diff against an
|
|
74
|
+
# inline requirement (--goal), with the diff↔coverage gate and the typed next-actions
|
|
75
|
+
# contract. Provider-key-free — this is the lite path that lets you taste the gate.
|
|
76
|
+
if verify or test_commands:
|
|
77
|
+
result = run_working_tree_check(
|
|
78
|
+
root,
|
|
79
|
+
goal,
|
|
80
|
+
test_commands=list(test_commands or []),
|
|
81
|
+
enforce_coverage=enforce_coverage,
|
|
82
|
+
min_ratio=min_coverage,
|
|
83
|
+
)
|
|
84
|
+
if json_format:
|
|
85
|
+
typer.echo(json.dumps(result.to_dict(), indent=2))
|
|
86
|
+
else:
|
|
87
|
+
_render_gate(result)
|
|
88
|
+
raise typer.Exit(code=0 if result.passed else 1)
|
|
89
|
+
|
|
90
|
+
verifier = Verifier(root)
|
|
91
|
+
|
|
92
|
+
logger.info("dev check (LLM audit): base=%s goal=%s", base or "working-tree", "set" if goal else "none")
|
|
93
|
+
diff = _diff(root, base)
|
|
94
|
+
if not diff.strip():
|
|
95
|
+
logger.info("dev check: clean working tree; nothing to audit")
|
|
96
|
+
msg = "No changes to check (clean working tree)."
|
|
97
|
+
typer.echo(json.dumps({"ok": True, "message": msg}) if json_format else msg)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
changed_files = verifier.get_changed_files()
|
|
101
|
+
secret_gaps = verifier.secret_scanner.scan_diff(diff, "check")
|
|
102
|
+
if secret_gaps:
|
|
103
|
+
logger.warning("dev check: %d possible secret(s) in diff across %d changed file(s)", len(secret_gaps), len(changed_files))
|
|
104
|
+
|
|
105
|
+
# Blast radius: what do the changed files ripple into? Surfaced from the
|
|
106
|
+
# structural graph when the code-review-graph integration is enabled, so a
|
|
107
|
+
# reviewer sees the impact (and which tests to run) before approving.
|
|
108
|
+
graph_context = CodeReviewGraphAdapter(root).get_context(changed_files)
|
|
109
|
+
|
|
110
|
+
findings = []
|
|
111
|
+
review_note = None
|
|
112
|
+
try:
|
|
113
|
+
config = load_config(root)
|
|
114
|
+
validate_model_provider(config.models.provider)
|
|
115
|
+
api_key = get_api_key(config.models.provider, root)
|
|
116
|
+
provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
|
|
117
|
+
role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
|
|
118
|
+
router = ModelRouter(provider, role_config, project_root=root)
|
|
119
|
+
synthetic = Task(
|
|
120
|
+
id="CHECK",
|
|
121
|
+
title="Ad-hoc change review",
|
|
122
|
+
description=(
|
|
123
|
+
goal
|
|
124
|
+
or "Review these changes for correctness, missing edge cases, error handling, "
|
|
125
|
+
"risky shortcuts, and scope creep."
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
review = asyncio.run(ImplementationReviewer(router).review_changes(synthetic, [], diff))
|
|
129
|
+
findings = review.findings
|
|
130
|
+
except (ProviderRequestError, StructuredOutputError) as exc:
|
|
131
|
+
logger.warning("dev check: LLM review unavailable: %s", exc)
|
|
132
|
+
review_note = f"LLM review unavailable: {exc}"
|
|
133
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
134
|
+
logger.warning("dev check: LLM review unavailable: %s", exc)
|
|
135
|
+
review_note = f"LLM review unavailable: {exc}"
|
|
136
|
+
logger.info("dev check audit complete: %d secret finding(s), %d review finding(s)", len(secret_gaps), len(findings))
|
|
137
|
+
|
|
138
|
+
if json_format:
|
|
139
|
+
typer.echo(json.dumps({
|
|
140
|
+
"ok": not secret_gaps,
|
|
141
|
+
"changed_files": changed_files,
|
|
142
|
+
"secret_findings": [g.model_dump() for g in secret_gaps],
|
|
143
|
+
"review_findings": [g.model_dump() for g in findings],
|
|
144
|
+
"review_note": review_note,
|
|
145
|
+
"blast_radius": {
|
|
146
|
+
"available": graph_context.available,
|
|
147
|
+
"impacted_files": graph_context.impacted_files,
|
|
148
|
+
"related_tests": graph_context.related_tests,
|
|
149
|
+
},
|
|
150
|
+
}, indent=2))
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
console.print(f"[bold]Changed files ({len(changed_files)}):[/bold] " + ", ".join(changed_files[:20]) or "(none)")
|
|
154
|
+
if secret_gaps:
|
|
155
|
+
console.print(f"\n[red bold]⚠ Possible secrets in the diff ({len(secret_gaps)}):[/red bold]")
|
|
156
|
+
for g in secret_gaps[:10]:
|
|
157
|
+
console.print(f" - {g.description[:100]}")
|
|
158
|
+
if findings:
|
|
159
|
+
console.print(f"\n[bold]Review findings ({len(findings)}):[/bold]")
|
|
160
|
+
for f in findings[:15]:
|
|
161
|
+
sev = getattr(f, "severity", "info")
|
|
162
|
+
colour = {"critical": "red", "high": "red", "medium": "yellow"}.get(sev, "white")
|
|
163
|
+
console.print(f" - [{colour}]{sev}[/{colour}]: {f.description[:140]}")
|
|
164
|
+
if graph_context.available and (graph_context.impacted_files or graph_context.related_tests):
|
|
165
|
+
console.print("\n[bold]Blast radius[/bold] [dim](from the structural graph)[/dim]:")
|
|
166
|
+
if graph_context.impacted_files:
|
|
167
|
+
console.print(f" [cyan]Impacted files ({len(graph_context.impacted_files)}):[/cyan] "
|
|
168
|
+
+ ", ".join(graph_context.impacted_files[:15]))
|
|
169
|
+
if graph_context.related_tests:
|
|
170
|
+
console.print(f" [cyan]Related tests ({len(graph_context.related_tests)}):[/cyan] "
|
|
171
|
+
+ ", ".join(graph_context.related_tests[:15]))
|
|
172
|
+
if not secret_gaps and not findings:
|
|
173
|
+
console.print("\n[green]No secrets or review concerns found in the diff.[/green]")
|
|
174
|
+
if review_note:
|
|
175
|
+
console.print(f"\n[dim]{review_note}[/dim]")
|
|
176
|
+
console.print(
|
|
177
|
+
"\n[dim]Tip: `dev check --goal \"what this change should do\"` sharpens the review.[/dim]"
|
|
178
|
+
)
|
|
179
|
+
console.print(
|
|
180
|
+
"[dim]Tip: `dev check --verify --test \"<cmd>\"` runs the deterministic evidence gate "
|
|
181
|
+
"(no provider keys).[/dim]"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _render_gate(result: AdHocCheckResult) -> None:
|
|
186
|
+
"""Render the deterministic evidence-gate result for humans."""
|
|
187
|
+
if result.reason == "no_changes":
|
|
188
|
+
console.print("[yellow]No working-tree changes to verify. Make a change first, then re-run.[/yellow]")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
console.print(f"[bold]Checking:[/bold] {result.requirement}")
|
|
192
|
+
console.print(f"[dim]{len(result.changed_files)} changed file(s) in scope.[/dim]")
|
|
193
|
+
if result.diff_coverage and result.diff_coverage.measured:
|
|
194
|
+
console.print(f"[dim]Diff coverage: {result.diff_coverage.summary}[/dim]")
|
|
195
|
+
|
|
196
|
+
if not result.gaps:
|
|
197
|
+
console.print("\n[green]Verified: the change is backed by passing evidence.[/green]")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
table = Table(title="Findings")
|
|
201
|
+
table.add_column("Type", style="cyan")
|
|
202
|
+
table.add_column("Severity", style="magenta")
|
|
203
|
+
table.add_column("Finding", style="white")
|
|
204
|
+
table.add_column("Blocking", style="red")
|
|
205
|
+
for gap in result.gaps[:20]:
|
|
206
|
+
table.add_row(gap.gap_type, gap.severity, gap.description, "YES" if gap.blocking else "no")
|
|
207
|
+
console.print(table)
|
|
208
|
+
|
|
209
|
+
if result.next_actions:
|
|
210
|
+
console.print("\n[bold]Next actions:[/bold]")
|
|
211
|
+
for action in result.next_actions:
|
|
212
|
+
location = ""
|
|
213
|
+
if action.file:
|
|
214
|
+
location = f" [dim]({action.file}{':' + str(action.line) if action.line else ''})[/dim]"
|
|
215
|
+
console.print(f" • [[cyan]{action.category}[/cyan]] {action.action}{location}")
|
|
216
|
+
|
|
217
|
+
if result.passed:
|
|
218
|
+
console.print("\n[green]Verified with non-blocking signals only.[/green]")
|
|
219
|
+
else:
|
|
220
|
+
console.print("\n[red]Not verified: blocking gaps must be resolved.[/red]")
|
|
@@ -2,8 +2,14 @@ import typer
|
|
|
2
2
|
import yaml
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from rich.console import Console
|
|
5
|
+
from devcouncil.cli.commands.init import parse_role_model_overrides
|
|
5
6
|
from devcouncil.app.config import load_config
|
|
6
|
-
from devcouncil.llm.provider import
|
|
7
|
+
from devcouncil.llm.provider import (
|
|
8
|
+
SUPPORTED_MODEL_PROVIDERS,
|
|
9
|
+
apply_provider_default_role_models,
|
|
10
|
+
build_role_model_config,
|
|
11
|
+
validate_model_provider,
|
|
12
|
+
)
|
|
7
13
|
|
|
8
14
|
app = typer.Typer(help="Manage DevCouncil configuration")
|
|
9
15
|
console = Console()
|
|
@@ -11,21 +17,34 @@ console = Console()
|
|
|
11
17
|
@app.command("models")
|
|
12
18
|
def models(
|
|
13
19
|
role: str = typer.Option(None, "--role", "-r", help="Specific role to show/edit"),
|
|
14
|
-
model: str = typer.Option(None, "--model", "-m", help="New model string to set for the role"),
|
|
20
|
+
model: str = typer.Option(None, "--model", "-m", help="New model string to set for the role, or every role when --role is omitted."),
|
|
15
21
|
provider: str = typer.Option(None, "--provider", help="Set the model provider."),
|
|
22
|
+
role_model: list[str] | None = typer.Option(
|
|
23
|
+
None,
|
|
24
|
+
"--role-model",
|
|
25
|
+
help="Per-role model override in ROLE=MODEL form. Can be repeated.",
|
|
26
|
+
),
|
|
27
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
16
28
|
):
|
|
17
29
|
"""View or edit model role configuration."""
|
|
30
|
+
root = project_root.expanduser().resolve()
|
|
18
31
|
try:
|
|
19
|
-
load_config(
|
|
32
|
+
load_config(root)
|
|
20
33
|
except FileNotFoundError as e:
|
|
21
34
|
console.print(f"[red]{e}[/red]")
|
|
22
35
|
return
|
|
23
36
|
|
|
24
|
-
config_path =
|
|
37
|
+
config_path = root / ".devcouncil" / "config.yaml"
|
|
25
38
|
|
|
26
39
|
with open(config_path) as f:
|
|
27
40
|
raw_config = yaml.safe_load(f) or {}
|
|
28
41
|
|
|
42
|
+
try:
|
|
43
|
+
role_models = parse_role_model_overrides(role_model)
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
console.print(f"[red]{e}[/red]")
|
|
46
|
+
raise typer.Exit(code=2) from e
|
|
47
|
+
|
|
29
48
|
if provider:
|
|
30
49
|
try:
|
|
31
50
|
normalized_provider = validate_model_provider(provider)
|
|
@@ -35,12 +54,32 @@ def models(
|
|
|
35
54
|
raw_config.setdefault("models", {})
|
|
36
55
|
previous = raw_config["models"].get("provider", "openrouter")
|
|
37
56
|
raw_config["models"]["provider"] = normalized_provider
|
|
57
|
+
updated_role_defaults = apply_provider_default_role_models(raw_config, previous, normalized_provider)
|
|
38
58
|
with open(config_path, "w") as f:
|
|
39
59
|
yaml.dump(raw_config, f, default_flow_style=False)
|
|
40
60
|
if previous == normalized_provider:
|
|
41
61
|
console.print(f"[green]Model provider remains '{normalized_provider}'.[/green]")
|
|
42
62
|
else:
|
|
43
63
|
console.print(f"[green]Updated model provider from '{previous}' to '{normalized_provider}'.[/green]")
|
|
64
|
+
if updated_role_defaults:
|
|
65
|
+
console.print(f"[green]Updated default role models for '{normalized_provider}'.[/green]")
|
|
66
|
+
if not model and not role_models:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if not role and (model or role_models):
|
|
70
|
+
raw_config.setdefault("models", {})
|
|
71
|
+
configured_provider = validate_model_provider(raw_config["models"].get("provider", "openrouter"))
|
|
72
|
+
raw_config["models"]["roles"] = build_role_model_config(
|
|
73
|
+
configured_provider,
|
|
74
|
+
model=model,
|
|
75
|
+
role_models=role_models,
|
|
76
|
+
)
|
|
77
|
+
with open(config_path, "w") as f:
|
|
78
|
+
yaml.dump(raw_config, f, default_flow_style=False)
|
|
79
|
+
if model:
|
|
80
|
+
console.print(f"[green]Updated all model roles to use '{model}'.[/green]")
|
|
81
|
+
for selected_role, selected_model in role_models.items():
|
|
82
|
+
console.print(f"[green]Updated '{selected_role}' to use model '{selected_model}'.[/green]")
|
|
44
83
|
return
|
|
45
84
|
|
|
46
85
|
if not role:
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from devcouncil.telemetry.cost import group_cost
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Inspect DevCouncil model-call cost, grouped by task and run.")
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("show")
|
|
15
|
+
def show(
|
|
16
|
+
json_format: bool = typer.Option(False, "--json", help="Output machine-readable JSON."),
|
|
17
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
18
|
+
):
|
|
19
|
+
"""Report estimated model-call cost grouped by task_id and run_id.
|
|
20
|
+
|
|
21
|
+
Reads the local ``model_calls.jsonl`` ledger only — fully offline. Records
|
|
22
|
+
written before per-task attribution (or made without a task/run context) are
|
|
23
|
+
grouped under ``(unattributed)``.
|
|
24
|
+
"""
|
|
25
|
+
root = project_root.expanduser().resolve()
|
|
26
|
+
summary = group_cost(root)
|
|
27
|
+
|
|
28
|
+
if json_format:
|
|
29
|
+
typer.echo(json.dumps(summary, indent=2))
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
console.print(
|
|
33
|
+
f"[bold]Total Cost:[/bold] ${summary['total_cost']:.4f} "
|
|
34
|
+
f"across {summary['total_calls']} model call(s)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _render(title: str, groups: dict) -> None:
|
|
38
|
+
if not groups:
|
|
39
|
+
return
|
|
40
|
+
table = Table(title=title)
|
|
41
|
+
table.add_column("Group", style="cyan")
|
|
42
|
+
table.add_column("Cost ($)", justify="right")
|
|
43
|
+
table.add_column("Calls", justify="right")
|
|
44
|
+
table.add_column("Prompt", justify="right")
|
|
45
|
+
table.add_column("Completion", justify="right")
|
|
46
|
+
for name, stats in sorted(groups.items(), key=lambda kv: kv[1]["cost"], reverse=True):
|
|
47
|
+
table.add_row(
|
|
48
|
+
name,
|
|
49
|
+
f"{stats['cost']:.4f}",
|
|
50
|
+
str(stats["calls"]),
|
|
51
|
+
str(stats["prompt_tokens"]),
|
|
52
|
+
str(stats["completion_tokens"]),
|
|
53
|
+
)
|
|
54
|
+
console.print(table)
|
|
55
|
+
|
|
56
|
+
_render("Cost by Task", summary["by_task"])
|
|
57
|
+
_render("Cost by Run", summary["by_run"])
|