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,267 @@
1
+ import asyncio
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.knowledge.frontmatter import build_frontmatter_markdown
9
+ from devcouncil.skills.registry import Skill, get_skill, load_skills, scaffold_skills, select_skills
10
+
11
+ app = typer.Typer(help="Inspect and scaffold DevCouncil engineering skills for coding agents.")
12
+ console = Console()
13
+
14
+
15
+ def _is_repo_skill(skill, project_root: Path) -> bool:
16
+ if skill.source_path is None:
17
+ return False
18
+ try:
19
+ skill.source_path.resolve().relative_to(project_root.resolve())
20
+ return True
21
+ except ValueError:
22
+ return False
23
+
24
+
25
+ @app.callback(invoke_without_command=True)
26
+ def skills(
27
+ ctx: typer.Context,
28
+ goal: str = typer.Option("", "--goal", help="Optional goal text; highlights the skills that would apply."),
29
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root used for file-based skill triggers."),
30
+ ):
31
+ """List available skills and show which apply to this repository/goal."""
32
+ if ctx.invoked_subcommand is not None:
33
+ return
34
+
35
+ root = project_root.expanduser().resolve()
36
+ all_skills = load_skills(project_root=root)
37
+ if not all_skills:
38
+ console.print("[yellow]No skills found in the DevCouncil skills library.[/yellow]")
39
+ raise typer.Exit()
40
+
41
+ selected = {skill.name for skill in select_skills(goal, root)}
42
+ table = Table(title="DevCouncil Skills")
43
+ table.add_column("Skill", style="cyan")
44
+ table.add_column("Source", justify="center")
45
+ table.add_column("Applies", justify="center")
46
+ table.add_column("Description")
47
+ for skill in all_skills:
48
+ applies = "always" if skill.always else ("yes" if skill.name in selected else "-")
49
+ style = "green" if skill.name in selected else "dim"
50
+ source = "repo" if _is_repo_skill(skill, root) else "library"
51
+ table.add_row(skill.name, source, f"[{style}]{applies}[/{style}]", skill.description)
52
+ console.print(table)
53
+ console.print(
54
+ "\nScaffold the applicable skills into this repo with: "
55
+ "[bold]dev skills scaffold[/bold] (add a goal to widen selection, or --all)."
56
+ )
57
+
58
+
59
+ @app.command("show")
60
+ def show(
61
+ name: str = typer.Argument(..., help="Skill name, e.g. core-engineering or android."),
62
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root, so repo-local skills are found too."),
63
+ ):
64
+ """Print the full body of a single skill."""
65
+ skill = get_skill(name, project_root=project_root.expanduser().resolve())
66
+ if skill is None:
67
+ console.print(f"[red]No skill named '{name}'. Run 'dev skills' to list available skills.[/red]")
68
+ raise typer.Exit(code=1)
69
+ console.print(skill.to_skill_md())
70
+
71
+
72
+ @app.command("scaffold")
73
+ def scaffold(
74
+ goal: str = typer.Argument("", help="Optional goal text used to widen domain-skill selection."),
75
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root to scaffold skills into."),
76
+ all_skills: bool = typer.Option(False, "--all", help="Scaffold every skill, not just the ones that apply."),
77
+ ):
78
+ """Write the applicable skills into <repo>/.claude/skills/<name>/SKILL.md."""
79
+ root = project_root.expanduser().resolve()
80
+ chosen = load_skills(project_root=root) if all_skills else select_skills(goal, root)
81
+ written = scaffold_skills(root, chosen)
82
+ if not written:
83
+ console.print(
84
+ f"[green]Skills already up to date in {root / '.claude' / 'skills'} "
85
+ f"({len(chosen)} applicable).[/green]"
86
+ )
87
+ return
88
+ console.print(f"[green]Wrote {len(written)} skill file(s):[/green]")
89
+ for path in written:
90
+ console.print(f" {path.relative_to(root).as_posix()}")
91
+
92
+
93
+ def _skill_to_markdown(skill: Skill, body: str) -> str:
94
+ """Render a skill back to markdown, preserving its selection frontmatter."""
95
+ meta: dict[str, object] = {"name": skill.name}
96
+ if skill.title:
97
+ meta["title"] = skill.title
98
+ if skill.description:
99
+ meta["description"] = skill.description
100
+ if skill.always:
101
+ meta["always"] = True
102
+ triggers = {
103
+ k: v
104
+ for k, v in {"keywords": skill.triggers.keywords, "globs": skill.triggers.globs}.items()
105
+ if v
106
+ }
107
+ if triggers:
108
+ meta["triggers"] = triggers
109
+ return build_frontmatter_markdown(meta, body)
110
+
111
+
112
+ def _write_skill_body(project_root: Path, skill: Skill, body: str) -> Path:
113
+ """Persist an optimized skill body, overwriting a repo-local skill in place or
114
+ materializing a packaged-library skill under ``.devcouncil/skills/<name>.md``."""
115
+ content = _skill_to_markdown(skill, body)
116
+ if skill.source_path is not None:
117
+ try:
118
+ skill.source_path.resolve().relative_to(project_root.resolve())
119
+ skill.source_path.write_text(content, encoding="utf-8")
120
+ return skill.source_path
121
+ except ValueError:
122
+ pass
123
+ target = project_root / ".devcouncil" / "skills" / f"{skill.name}.md"
124
+ target.parent.mkdir(parents=True, exist_ok=True)
125
+ target.write_text(content, encoding="utf-8")
126
+ return target
127
+
128
+
129
+ def _build_router(project_root: Path):
130
+ """Build a ModelRouter from project config, adding SkillOpt roles when absent."""
131
+ from devcouncil.app.config import get_api_key, load_config
132
+ from devcouncil.llm.provider import create_provider
133
+ from devcouncil.llm.router import ModelRouter
134
+
135
+ config = load_config(project_root)
136
+ api_key = get_api_key(config.models.provider, project_root)
137
+ provider = create_provider(config.models.provider, api_key, project_root=project_root, provider_prefs=config.provider)
138
+ role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
139
+ if not role_config:
140
+ raise RuntimeError(
141
+ "No model roles configured in .devcouncil/config.yaml. "
142
+ "Run 'dev init' or add a 'models.roles' entry before optimizing."
143
+ )
144
+ # SkillOpt's rollout/optimizer roles fall back to a capable existing role when the
145
+ # project config doesn't define dedicated ones. Copy the dict so the three roles
146
+ # don't alias one config object.
147
+ capable = role_config.get("arbiter") or role_config.get("planner_a") or next(iter(role_config.values()))
148
+ role_config.setdefault("skill_target", dict(capable))
149
+ role_config.setdefault("skill_optimizer", dict(capable))
150
+ return ModelRouter(provider, role_config, project_root=project_root)
151
+
152
+
153
+ @app.command("optimize")
154
+ def optimize(
155
+ name: str = typer.Argument(..., help="Skill name to optimize, e.g. core-engineering."),
156
+ evals_path: Path = typer.Option(..., "--evals", help="JSON or JSONL dataset of evaluation tasks."),
157
+ profile_name: str = typer.Option(
158
+ "default", "--profile", help="Agent profile whose prompt preamble (guidance) is co-optimized."
159
+ ),
160
+ epochs: int = typer.Option(5, "--epochs", min=1, help="Optimization epochs."),
161
+ max_edits: int = typer.Option(3, "--max-edits", min=1, help="Edit budget per epoch (textual learning rate)."),
162
+ val_fraction: float = typer.Option(0.5, "--val-fraction", min=0.0, max=1.0, help="Held-out validation fraction."),
163
+ seed: int = typer.Option(0, "--seed", help="Seed for the deterministic train/validation split."),
164
+ apply: bool = typer.Option(
165
+ False,
166
+ "--apply/--dry-run",
167
+ help="Write the optimized skill body and guidance preamble back to disk. Defaults to dry-run.",
168
+ ),
169
+ output_path: Path | None = typer.Option(None, "--output", help="Write the optimization artifact to this path."),
170
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
171
+ ):
172
+ """Co-optimize a skill document and its agent guidance preamble with the SkillOpt loop.
173
+
174
+ Each epoch runs the skill+guidance on training tasks, scores the rollouts, and lets an
175
+ optimizer model propose bounded edits to **both** documents at once; a candidate is kept
176
+ only if it strictly improves the held-out validation score.
177
+ """
178
+ from devcouncil.executors.agent_registry import load_agent_profiles
179
+ from devcouncil.optimization.gepa_agent import load_agent_eval_dataset
180
+ from devcouncil.optimization.skillopt import (
181
+ GUIDANCE,
182
+ SKILL,
183
+ DEFAULT_OBJECTIVE,
184
+ SkillOptConfig,
185
+ default_artifact_path,
186
+ make_llm_optimizer,
187
+ make_llm_rollout,
188
+ optimize_skill,
189
+ write_result_artifact,
190
+ )
191
+ from devcouncil.optimization.gepa_agent import _apply_profile_preamble
192
+
193
+ root = project_root.expanduser().resolve()
194
+ skill = get_skill(name, project_root=root)
195
+ if skill is None:
196
+ console.print(f"[red]No skill named '{name}'. Run 'dev skills' to list available skills.[/red]")
197
+ raise typer.Exit(code=1)
198
+
199
+ resolved_evals = evals_path.expanduser()
200
+ if not resolved_evals.is_absolute():
201
+ resolved_evals = root / resolved_evals
202
+ try:
203
+ dataset = load_agent_eval_dataset(resolved_evals)
204
+ except ValueError as exc:
205
+ console.print(f"[red]{exc}[/red]")
206
+ raise typer.Exit(code=2) from exc
207
+
208
+ profiles = load_agent_profiles(root)
209
+ profile = profiles.get(profile_name)
210
+ if profile is None:
211
+ known = ", ".join(sorted(profiles)) or "(none)"
212
+ console.print(
213
+ f"[red]No agent profile named '{profile_name}'. Known profiles: {known}.[/red]"
214
+ )
215
+ raise typer.Exit(code=2)
216
+ guidance = profile.prompt_preamble or ""
217
+
218
+ try:
219
+ router = _build_router(root)
220
+ except (RuntimeError, ValueError, FileNotFoundError) as exc:
221
+ console.print(f"[red]{exc}[/red]")
222
+ raise typer.Exit(code=1) from exc
223
+
224
+ rollout = make_llm_rollout(router)
225
+ optimizer = make_llm_optimizer(router)
226
+ result = asyncio.run(
227
+ optimize_skill(
228
+ skill_name=skill.name,
229
+ docs={GUIDANCE: guidance, SKILL: skill.body},
230
+ dataset=dataset,
231
+ rollout=rollout,
232
+ optimizer=optimizer,
233
+ config=SkillOptConfig(
234
+ epochs=epochs, max_edits_per_epoch=max_edits, val_fraction=val_fraction, seed=seed
235
+ ),
236
+ )
237
+ )
238
+
239
+ artifact_path = (output_path or default_artifact_path(root, skill.name)).expanduser()
240
+ if not artifact_path.is_absolute():
241
+ artifact_path = root / artifact_path
242
+ result.artifact_path = artifact_path
243
+ result.applied = apply
244
+ write_result_artifact(
245
+ artifact_path, result, objective=DEFAULT_OBJECTIVE, dataset_path=str(resolved_evals)
246
+ )
247
+
248
+ if apply and result.improved:
249
+ # Only write a document that actually changed, so a guidance-only improvement
250
+ # doesn't churn the skill file (and vice versa).
251
+ if result.best_skill_body != skill.body:
252
+ skill_path = _write_skill_body(root, skill, result.best_skill_body)
253
+ console.print(f"[green]Updated skill body:[/green] {skill_path.relative_to(root).as_posix()}")
254
+ if result.best_guidance_body != guidance:
255
+ _apply_profile_preamble(root, profile_name, result.best_guidance_body)
256
+ console.print(f"[green]Updated guidance preamble for profile '{profile_name}'.[/green]")
257
+
258
+ mode = "applied" if (apply and result.improved) else "dry-run"
259
+ console.print(
260
+ f"[green]SkillOpt complete ({mode}) for '{skill.name}'.[/green] "
261
+ f"validation {result.seed_val_score:.3f} -> {result.best_val_score:.3f} "
262
+ f"over {len(result.epochs)} epoch(s), "
263
+ f"{result.accepted_edit_count} edit(s) accepted, {result.rejected_edit_count} rejected."
264
+ )
265
+ console.print(f"Artifact: [dim]{artifact_path}[/dim]")
266
+ if apply and not result.improved:
267
+ console.print("[yellow]No validated improvement — nothing written. Re-run with more epochs or data.[/yellow]")
@@ -8,7 +8,7 @@ from devcouncil.cli.commands.init import initialize_project
8
8
  from devcouncil.app.project_status import compute_phase
9
9
  from devcouncil.storage.db import get_db
10
10
  from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
11
- from devcouncil.telemetry.cost import CostEstimator
11
+ from devcouncil.telemetry.cost import group_cost
12
12
  from devcouncil.live.summary import live_review_summary
13
13
 
14
14
  console = Console()
@@ -28,19 +28,10 @@ def _status_payload(project_root: Path) -> dict:
28
28
  state = StateRepository(session).get_state()
29
29
  phase = compute_phase(graph, state.current_phase if state else None)
30
30
 
31
- total_cost = 0.0
32
- log_file = project_root / ".devcouncil" / "logs" / "model_calls.jsonl"
33
- if log_file.exists():
34
- with open(log_file, "r", encoding="utf-8") as f:
35
- for line in f:
36
- try:
37
- entry = json.loads(line)
38
- total_cost += CostEstimator.estimate_cost(
39
- entry.get("response", {}).get("model", ""),
40
- entry.get("usage", {}),
41
- )
42
- except Exception:
43
- continue
31
+ # Single read of the model-call ledger: derive both the grand total and the
32
+ # per-task breakdown from one pass (group_cost -> read_cost_records). This is
33
+ # provider-aware (ollama records are free), matching the Cost-by-Task table.
34
+ cost = group_cost(project_root)
44
35
 
45
36
  status_counts: dict[str, int] = {}
46
37
  for task in graph.tasks.values():
@@ -50,7 +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,
44
+ "total_cost": cost["total_cost"],
45
+ "cost_by_task": cost["by_task"],
54
46
  "task_status_counts": status_counts,
55
47
  "blocking_gaps": [gap.model_dump() for gap in blocking_gaps],
56
48
  "live_review": live_review_summary(project_root),
@@ -59,6 +51,11 @@ def _status_payload(project_root: Path) -> dict:
59
51
 
60
52
  def status(
61
53
  json_format: bool = typer.Option(False, "--json", help="Output machine-readable JSON."),
54
+ fail_on_blocking: bool = typer.Option(
55
+ False,
56
+ "--fail-on-blocking",
57
+ help="Exit non-zero when blocking gaps remain, so shell-driven agents can gate on $?.",
58
+ ),
62
59
  project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
63
60
  ):
64
61
  """
@@ -66,8 +63,14 @@ def status(
66
63
  """
67
64
  root = project_root.expanduser().resolve()
68
65
  payload = _status_payload(root)
66
+
67
+ def _maybe_fail() -> None:
68
+ if fail_on_blocking and payload.get("blocking_gaps"):
69
+ raise typer.Exit(code=1)
70
+
69
71
  if json_format:
70
72
  typer.echo(json.dumps(payload, indent=2))
73
+ _maybe_fail()
71
74
  return
72
75
 
73
76
  if not payload["initialized"]:
@@ -108,6 +111,16 @@ def status(
108
111
  table.add_row(state, str(count))
109
112
  console.print(table)
110
113
 
114
+ cost_groups = payload.get("cost_by_task") or {}
115
+ if cost_groups:
116
+ cost_table = Table(title="Cost by Task")
117
+ cost_table.add_column("Task", style="cyan")
118
+ cost_table.add_column("Cost ($)", justify="right")
119
+ cost_table.add_column("Calls", justify="right")
120
+ for name, stats in sorted(cost_groups.items(), key=lambda kv: kv[1]["cost"], reverse=True):
121
+ cost_table.add_row(name, f"{stats['cost']:.4f}", str(stats["calls"]))
122
+ console.print(cost_table)
123
+
111
124
  blocking_gaps = payload["blocking_gaps"]
112
125
  if blocking_gaps:
113
126
  console.print(f"\n[red bold]WARNING: {len(blocking_gaps)} blocking gap(s) must be resolved:[/red bold]")
@@ -115,3 +128,5 @@ def status(
115
128
  console.print(f" - [red]{gap['id']}[/red]: {gap['description'][:80]}")
116
129
  if len(blocking_gaps) > 5:
117
130
  console.print(f" ... and {len(blocking_gaps) - 5} more. Run [bold]dev report[/bold] for details.")
131
+
132
+ _maybe_fail()
@@ -1,11 +1,12 @@
1
1
  import json
2
2
  import time
3
3
  from pathlib import Path
4
+ from typing import Optional
4
5
 
5
6
  import typer
6
7
  from rich.console import Console
7
8
 
8
- from devcouncil.telemetry.traces import read_trace_events
9
+ from devcouncil.telemetry.traces import read_trace_events, read_trace_events_since
9
10
 
10
11
  app = typer.Typer(help="Inspect DevCouncil trace events.")
11
12
  console = Console()
@@ -13,13 +14,56 @@ console = Console()
13
14
 
14
15
  @app.command("tail")
15
16
  def tail(
16
- follow: bool = typer.Option(False, "--follow", "-f", help="Continue polling for new events."),
17
+ follow: bool = typer.Option(
18
+ False, "--follow/--no-follow", "-f", help="Continue polling for new events (default is a single shot)."
19
+ ),
17
20
  limit: int = typer.Option(50, "--limit", "-n", help="Maximum events to print before following."),
18
21
  jsonl: bool = typer.Option(True, "--jsonl/--pretty", help="Print JSONL or compact text rows."),
22
+ since: Optional[int] = typer.Option(
23
+ None,
24
+ "--since",
25
+ help="Byte-offset cursor from a previous run; emit only events after it (stateless incremental polling).",
26
+ ),
27
+ json_summary: bool = typer.Option(
28
+ False,
29
+ "--json",
30
+ help="Emit a single {events, next_cursor} JSON object (incremental mode). Implies --no-follow.",
31
+ ),
19
32
  project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
20
33
  ):
21
- """Print the DevCouncil trace JSONL stream for replay or debugging."""
34
+ """Print the DevCouncil trace JSONL stream for replay or debugging.
35
+
36
+ With ``--since <cursor> --no-follow`` (or ``--json``) a single-shot supervising
37
+ agent gets only the events appended after the cursor plus a ``next_cursor`` to
38
+ pass back on the next poll, so each poll is O(new) rather than O(all).
39
+ """
22
40
  project_root = project_root.expanduser().resolve()
41
+
42
+ # Incremental cursor mode: any of --since / --json / explicit --no-follow.
43
+ incremental = since is not None or json_summary
44
+ if incremental:
45
+ events, next_cursor = read_trace_events_since(project_root, since)
46
+ if json_summary:
47
+ typer.echo(
48
+ json.dumps(
49
+ {
50
+ "events": [event.model_dump(by_alias=True) for event in events],
51
+ "next_cursor": next_cursor,
52
+ }
53
+ )
54
+ )
55
+ return
56
+ for event in events:
57
+ if jsonl:
58
+ typer.echo(event.model_dump_json())
59
+ else:
60
+ console.print(
61
+ f"{event.timestamp} {event.type} "
62
+ f"{event.task_id or '-'} {event.summary or json.dumps(event.details)}"
63
+ )
64
+ console.print(f"[dim]next_cursor: {next_cursor}[/dim]")
65
+ return
66
+
23
67
  printed = 0
24
68
 
25
69
  def emit_new(start_index: int) -> int:
@@ -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
@@ -11,17 +12,44 @@ from devcouncil.storage.repositories import TaskRepository, RequirementRepositor
11
12
  from devcouncil.verification.verifier import Verifier
12
13
  from devcouncil.llm.provider import create_provider, validate_model_provider
13
14
  from devcouncil.llm.router import ModelRouter
14
- from devcouncil.domain.evidence import CommandResult, DiffEvidence, TestEvidence
15
+ from devcouncil.domain.evidence import CommandResult, DiffEvidence, DiffCoverageEvidence, TestEvidence
16
+ from devcouncil.domain.gap import Gap
17
+ from devcouncil.verification.next_actions import split_next_actions
15
18
  from devcouncil.app.config import load_config, get_api_key
16
19
  from devcouncil.app.state_machine import ProjectPhase
17
20
  from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
18
21
  from devcouncil.telemetry.traces import TraceLogger
19
22
 
20
23
  console = Console()
24
+ logger = logging.getLogger(__name__)
21
25
  MAX_RENDERED_GAPS = 20
22
26
 
27
+
28
+ def reconcile_cross_task_acceptance(
29
+ gaps: list[Gap], proven_acs: set[str]
30
+ ) -> list[Gap]:
31
+ """Drop a task's blocking ``acceptance_criteria_unproven`` gaps whose criterion is
32
+ already proven by passing evidence in another task.
33
+
34
+ Acceptance criteria are requirement-level, not task-private: when the planner splits
35
+ "implement X" and "add tests for X" into separate tasks that share criteria, the
36
+ implement task would otherwise stay blocked for criteria the test task proved. The
37
+ caller passes ``proven_acs`` gathered from passing evidence re-run against the current
38
+ tree, so a regression would have failed the test and excluded the criterion — only
39
+ genuinely-satisfied criteria are cleared. Returns the gaps to keep."""
40
+ return [
41
+ gap
42
+ for gap in gaps
43
+ if not (
44
+ gap.blocking
45
+ and gap.gap_type == "acceptance_criteria_unproven"
46
+ and gap.acceptance_criterion_id in proven_acs
47
+ )
48
+ ]
49
+
23
50
  def verify(
24
51
  task_id: Optional[str] = typer.Argument(None, help="Optional ID of the task to verify"),
52
+ sandbox: str = typer.Option("local", "--sandbox", help="Verification sandbox: local, docker, or nix."),
25
53
  json_format: bool = typer.Option(False, "--json", help="Output machine-readable JSON."),
26
54
  project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
27
55
  ):
@@ -29,6 +57,9 @@ def verify(
29
57
  Verify one task, or all tasks when TASK_ID is omitted.
30
58
  """
31
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)
32
63
  initialize_project(root, quiet=True)
33
64
  db = get_db(root)
34
65
  if not db:
@@ -62,18 +93,69 @@ def verify(
62
93
  config = load_config(root)
63
94
  validate_model_provider(config.models.provider)
64
95
  api_key = get_api_key(config.models.provider, root)
65
- provider = create_provider(config.models.provider, api_key)
96
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
66
97
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
67
- router = ModelRouter(provider, role_config)
98
+ router = ModelRouter(provider, role_config, project_root=root)
68
99
  except Exception:
69
100
  pass
70
101
 
102
+ from devcouncil.verification.sandbox import get_sandbox
103
+
71
104
  verifier = Verifier(root, router=router)
72
105
  total_gaps = 0
73
106
  blocked_tasks = 0
74
107
  task_results = []
108
+ # Cross-task acceptance reconciliation state: a criterion proven by passing
109
+ # evidence in ANY task is proven for every task that shares it.
110
+ proven_acs: set[str] = set()
111
+ per_task_gaps: dict[str, list] = {}
75
112
 
76
113
  for task in tasks:
114
+ if sandbox != "local":
115
+ commands = task.expected_tests or task.allowed_commands
116
+ sandbox_result = get_sandbox(sandbox, root).run(task, commands, reqs)
117
+ if sandbox_result.status == "unsupported":
118
+ message = f"Sandbox {sandbox} is unavailable."
119
+ if json_format:
120
+ typer.echo(json.dumps({"ok": False, "error": message, "sandbox": sandbox}, indent=2))
121
+ else:
122
+ console.print(f"[red]{message}[/red]")
123
+ return
124
+ if sandbox_result.status == "failed":
125
+ task.status = "blocked"
126
+ blocked_tasks += 1
127
+ task_repo.save(task)
128
+ task_results.append({
129
+ "task_id": task.id,
130
+ "status": task.status,
131
+ "sandbox": sandbox,
132
+ "gap_count": 1,
133
+ "blocking_gap_count": 1,
134
+ "gaps": [],
135
+ })
136
+ if json_format:
137
+ typer.echo(json.dumps({
138
+ "ok": False,
139
+ "task_id": task.id,
140
+ "sandbox": sandbox,
141
+ "commands": sandbox_result.commands,
142
+ }, indent=2))
143
+ else:
144
+ console.print(f"[red]{task.id} failed in {sandbox} sandbox.[/red]")
145
+ continue
146
+ task.status = "verified"
147
+ task_repo.save(task)
148
+ task_results.append({
149
+ "task_id": task.id,
150
+ "status": task.status,
151
+ "sandbox": sandbox,
152
+ "gap_count": 0,
153
+ "blocking_gap_count": 0,
154
+ "gaps": [],
155
+ })
156
+ if not json_format:
157
+ console.print(f"[green]{task.id} passed in {sandbox} sandbox.[/green]")
158
+ continue
77
159
  TraceLogger(root).log_event(
78
160
  "task_verification_started",
79
161
  {"task_id": task.id},
@@ -95,6 +177,7 @@ def verify(
95
177
  evidence_repo.delete_for_task(task.id)
96
178
 
97
179
  gaps, evidence = asyncio.run(verifier.verify_task(task, reqs))
180
+ outcome = verifier.last_outcome
98
181
  total_gaps += len(gaps)
99
182
 
100
183
  for gap in gaps:
@@ -103,11 +186,16 @@ def verify(
103
186
  for ev in evidence:
104
187
  if isinstance(ev, CommandResult):
105
188
  evidence_repo.save_command_result(task.id, ev)
189
+ elif isinstance(ev, DiffCoverageEvidence):
190
+ evidence_repo.save_diff_coverage_evidence(ev)
106
191
  elif isinstance(ev, DiffEvidence):
107
192
  evidence_repo.save_diff_evidence(ev)
108
193
  elif isinstance(ev, TestEvidence):
109
194
  evidence_repo.save_test_evidence(ev, task.id)
195
+ if ev.status == "passed" and ev.acceptance_criterion_id:
196
+ proven_acs.add(ev.acceptance_criterion_id)
110
197
 
198
+ per_task_gaps[task.id] = gaps
111
199
  if not json_format:
112
200
  _print_task_result(task.id, gaps)
113
201
 
@@ -129,14 +217,60 @@ def verify(
129
217
  summary=f"{task.id} verified",
130
218
  )
131
219
  task_repo.save(task)
220
+ blocking_actions, advisory_actions = split_next_actions(gaps)
132
221
  task_results.append({
133
222
  "task_id": task.id,
134
223
  "status": task.status,
135
224
  "gap_count": len(gaps),
136
225
  "blocking_gap_count": len([gap for gap in gaps if gap.blocking]),
137
226
  "gaps": [gap.model_dump() for gap in gaps],
227
+ "next_actions": [action.model_dump() for action in blocking_actions],
228
+ "advisory_actions": [action.model_dump() for action in advisory_actions],
229
+ "verification_mode": outcome.mode if outcome else "unknown",
230
+ "compiler_active": outcome.compiler_active if outcome else False,
231
+ "diff_empty": outcome.diff_empty if outcome else False,
232
+ "coverage_measured": outcome.coverage_measured if outcome else False,
233
+ "coverage_skipped_reason": outcome.coverage_skipped_reason if outcome else None,
138
234
  })
139
235
 
236
+ # Cross-task acceptance reconciliation (only meaningful across the full set).
237
+ # The planner sometimes splits "implement X" and "add tests for X" into separate
238
+ # tasks that share acceptance criteria; the implement task would otherwise stay
239
+ # blocked for criteria the test task already proved. A criterion proven by passing
240
+ # evidence in ANY task is proven for every task that shares it. Evidence was re-run
241
+ # against the current tree, so a regression would have failed the test and the AC
242
+ # would not be in proven_acs — this clears only genuinely-satisfied criteria.
243
+ if task_id is None and proven_acs:
244
+ result_map = {r["task_id"]: r for r in task_results}
245
+ for task in tasks:
246
+ gaps = per_task_gaps.get(task.id, [])
247
+ kept = reconcile_cross_task_acceptance(gaps, proven_acs)
248
+ if len(kept) == len(gaps):
249
+ continue
250
+ gap_repo.delete_for_task(task.id)
251
+ for gap in kept:
252
+ gap_repo.save(gap)
253
+ per_task_gaps[task.id] = kept
254
+ if task.status == "blocked" and not any(gap.blocking for gap in kept):
255
+ task.status = "verified"
256
+ blocked_tasks = max(0, blocked_tasks - 1)
257
+ TraceLogger(root).log_event(
258
+ "task_reconciled",
259
+ {"task_id": task.id, "cross_task_proven": True},
260
+ task_id=task.id,
261
+ summary=f"{task.id} verified via cross-task acceptance reconciliation",
262
+ )
263
+ task_repo.save(task)
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]
273
+
140
274
  StateRepository(session).record_phase(
141
275
  ProjectPhase.TASK_BLOCKED.value if blocked_tasks else ProjectPhase.TASK_VERIFIED.value
142
276
  )
@@ -158,6 +292,13 @@ def verify(
158
292
  else:
159
293
  console.print(f"\n[green]Verified {len(tasks)} tasks successfully.[/green]")
160
294
 
295
+ # Exit-code contract (so shell-driven agents can gate on $?):
296
+ # 0 = all verified, no blocking gaps
297
+ # 1 = at least one task is blocked by a verification gap
298
+ # Argument/state errors above return early with their own message and exit 0.
299
+ if blocked_tasks:
300
+ raise typer.Exit(code=1)
301
+
161
302
 
162
303
  def _print_task_result(task_id: str, gaps):
163
304
  if not gaps: