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,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
|
|
11
|
+
from devcouncil.telemetry.cost import group_cost
|
|
12
12
|
from devcouncil.live.summary import live_review_summary
|
|
13
13
|
|
|
14
14
|
console = Console()
|
|
@@ -28,19 +28,10 @@ def _status_payload(project_root: Path) -> dict:
|
|
|
28
28
|
state = StateRepository(session).get_state()
|
|
29
29
|
phase = compute_phase(graph, state.current_phase if state else None)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
for line in f:
|
|
36
|
-
try:
|
|
37
|
-
entry = json.loads(line)
|
|
38
|
-
total_cost += CostEstimator.estimate_cost(
|
|
39
|
-
entry.get("response", {}).get("model", ""),
|
|
40
|
-
entry.get("usage", {}),
|
|
41
|
-
)
|
|
42
|
-
except Exception:
|
|
43
|
-
continue
|
|
31
|
+
# Single read of the model-call ledger: derive both the grand total and the
|
|
32
|
+
# per-task breakdown from one pass (group_cost -> read_cost_records). This is
|
|
33
|
+
# provider-aware (ollama records are free), matching the Cost-by-Task table.
|
|
34
|
+
cost = group_cost(project_root)
|
|
44
35
|
|
|
45
36
|
status_counts: dict[str, int] = {}
|
|
46
37
|
for task in graph.tasks.values():
|
|
@@ -50,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(
|
|
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:
|