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.
Files changed (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  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 +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. 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
- initialize_project(Path("."), quiet=True)
20
- db = get_db()
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 SUPPORTED_MODEL_PROVIDERS, validate_model_provider
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(Path("."))
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 = Path(".devcouncil/config.yaml")
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"])