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
|
@@ -2,6 +2,7 @@ import typer
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import json
|
|
4
4
|
import datetime
|
|
5
|
+
import logging
|
|
5
6
|
from typing import Any
|
|
6
7
|
from rich.console import Console
|
|
7
8
|
from rich.panel import Panel
|
|
@@ -15,24 +16,25 @@ from devcouncil.storage.repositories import (
|
|
|
15
16
|
)
|
|
16
17
|
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
17
18
|
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
18
|
-
from devcouncil.llm.provider import MockProvider, create_provider, validate_model_provider
|
|
19
|
-
from devcouncil.llm.router import ModelRouter
|
|
19
|
+
from devcouncil.llm.provider import Provider, MockProvider, ProviderRequestError, build_role_model_config, create_provider, validate_model_provider
|
|
20
|
+
from devcouncil.llm.router import ModelRouter, StructuredOutputError
|
|
20
21
|
from devcouncil.planning.spec_service import SpecService
|
|
21
|
-
from devcouncil.planning.prompt_enhancer_service import PromptEnhancerService
|
|
22
|
-
from devcouncil.planning.plan_service import PlanService
|
|
22
|
+
from devcouncil.planning.prompt_enhancer_service import PromptEnhancerService, save_active_prompt_enhancement
|
|
23
|
+
from devcouncil.planning.plan_service import PlanService, backfill_acceptance_criteria
|
|
23
24
|
from devcouncil.planning.critique_service import CritiqueService
|
|
24
|
-
from devcouncil.planning.arbiter_service import ArbiterService
|
|
25
|
+
from devcouncil.planning.arbiter_service import ArbiterDecision, ArbiterService
|
|
25
26
|
from devcouncil.gating.policy import GatePolicy
|
|
26
27
|
from devcouncil.app.orchestrator import Orchestrator
|
|
27
28
|
from devcouncil.app.state_machine import ProjectPhase
|
|
28
29
|
from devcouncil.app.config import ModelRoleConfig, load_config, get_api_key
|
|
29
30
|
from devcouncil.cli.commands.init import initialize_project
|
|
30
31
|
from devcouncil.telemetry.traces import TraceLogger
|
|
32
|
+
from devcouncil.telemetry.stages import log_step
|
|
31
33
|
|
|
32
34
|
app = typer.Typer()
|
|
33
35
|
console = Console()
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
34
37
|
|
|
35
|
-
DEFAULT_PLANNING_MODEL = "anthropic/claude-3.5-sonnet"
|
|
36
38
|
REQUIRED_PLANNING_ROLES = (
|
|
37
39
|
"prompt_enhancer",
|
|
38
40
|
"spec_writer",
|
|
@@ -75,7 +77,11 @@ def _ensure_planning_roles(config) -> None:
|
|
|
75
77
|
if fallback is None and config.models.roles:
|
|
76
78
|
fallback = next(iter(config.models.roles.values()))
|
|
77
79
|
if fallback is None:
|
|
78
|
-
|
|
80
|
+
try:
|
|
81
|
+
provider_roles = build_role_model_config(config.models.provider)
|
|
82
|
+
fallback = ModelRoleConfig(model=provider_roles["spec_writer"]["model"])
|
|
83
|
+
except ValueError:
|
|
84
|
+
fallback = ModelRoleConfig(model="unconfigured")
|
|
79
85
|
|
|
80
86
|
for role in REQUIRED_PLANNING_ROLES:
|
|
81
87
|
config.models.roles.setdefault(role, fallback.model_copy())
|
|
@@ -86,8 +92,11 @@ async def run_plan_flow(
|
|
|
86
92
|
dry_run: bool = False,
|
|
87
93
|
persist: bool = True,
|
|
88
94
|
project_root: Path = Path("."),
|
|
95
|
+
quick: bool = False,
|
|
89
96
|
):
|
|
90
97
|
root = project_root.expanduser().resolve()
|
|
98
|
+
from devcouncil.telemetry.logging_setup import set_log_dir
|
|
99
|
+
set_log_dir(root)
|
|
91
100
|
initialize_project(root, quiet=True)
|
|
92
101
|
db = get_db(root)
|
|
93
102
|
if not db:
|
|
@@ -112,6 +121,7 @@ async def run_plan_flow(
|
|
|
112
121
|
run_id = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-plan"
|
|
113
122
|
await orchestrator.start_run(run_id, goal)
|
|
114
123
|
|
|
124
|
+
provider: Provider
|
|
115
125
|
if dry_run:
|
|
116
126
|
# Override config models to be unique roles for mock mapping
|
|
117
127
|
for role in REQUIRED_PLANNING_ROLES:
|
|
@@ -158,11 +168,14 @@ async def run_plan_flow(
|
|
|
158
168
|
# I'll modify PlanService to use a slightly different role string if needed,
|
|
159
169
|
# but for Dry Run, let's just make the MockProvider return based on the schema requested.
|
|
160
170
|
else:
|
|
161
|
-
|
|
171
|
+
if api_key is None:
|
|
172
|
+
console.print("[red]Missing API key for configured model provider.[/red]")
|
|
173
|
+
return []
|
|
174
|
+
provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
|
|
162
175
|
|
|
163
176
|
# Build role config after dry-run overrides so mocks are routed correctly.
|
|
164
177
|
role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
|
|
165
|
-
router = ModelRouter(provider, role_config)
|
|
178
|
+
router = ModelRouter(provider, role_config, project_root=root)
|
|
166
179
|
|
|
167
180
|
prompt_enhancer = PromptEnhancerService(router)
|
|
168
181
|
spec_service = SpecService(router)
|
|
@@ -177,24 +190,34 @@ async def run_plan_flow(
|
|
|
177
190
|
transient=True,
|
|
178
191
|
) as progress:
|
|
179
192
|
# 1. Repo Map
|
|
193
|
+
log_step("plan/1: mapping repository", project_root=root, run_id=run_id)
|
|
180
194
|
progress.add_task(description="Mapping repository...", total=None)
|
|
181
195
|
repo_map = mapper.map_repo(goal)
|
|
182
196
|
repo_map_json = repo_map.model_dump_json(indent=2)
|
|
183
|
-
|
|
197
|
+
# save_run_artifact re-serializes via json.dump, so pass the dict directly
|
|
198
|
+
# instead of round-tripping the already-serialized JSON back through json.loads.
|
|
199
|
+
orchestrator.save_run_artifact("repo_map.json", repo_map.model_dump(mode="json"))
|
|
184
200
|
graph_context = CodeReviewGraphAdapter(root).get_context()
|
|
185
201
|
if graph_context.available:
|
|
186
202
|
orchestrator.save_run_artifact("code_review_graph_context.json", graph_context.model_dump())
|
|
187
203
|
await orchestrator.transition_to(ProjectPhase.REPO_MAPPED)
|
|
188
204
|
|
|
189
205
|
# 2. Codebase-specific prompt enhancement
|
|
206
|
+
log_step("plan/2: enhancing prompt for codebase debate", project_root=root, run_id=run_id)
|
|
190
207
|
progress.add_task(description="Enhancing prompt for codebase debate...", total=None)
|
|
191
208
|
prompt_enhancement = await prompt_enhancer.enhance_prompt(
|
|
192
209
|
goal,
|
|
193
210
|
repo_map_json,
|
|
194
211
|
graph_context.model_dump_json() if graph_context.available else None,
|
|
212
|
+
project_root=root,
|
|
195
213
|
)
|
|
196
214
|
debate_goal = prompt_enhancement.debate_prompt()
|
|
197
215
|
orchestrator.save_run_artifact("prompt_enhancement.json", prompt_enhancement.model_dump())
|
|
216
|
+
if prompt_enhancement.applied_skills:
|
|
217
|
+
console.print(
|
|
218
|
+
"[dim]Domain skills applied:[/dim] "
|
|
219
|
+
+ ", ".join(prompt_enhancement.applied_skills)
|
|
220
|
+
)
|
|
198
221
|
TraceLogger(root).log_event(
|
|
199
222
|
"prompt_enhanced",
|
|
200
223
|
{
|
|
@@ -203,6 +226,7 @@ async def run_plan_flow(
|
|
|
203
226
|
"codebase_context_count": len(prompt_enhancement.codebase_context),
|
|
204
227
|
"constraint_count": len(prompt_enhancement.constraints),
|
|
205
228
|
"debate_focus_count": len(prompt_enhancement.debate_focus),
|
|
229
|
+
"applied_skills": prompt_enhancement.applied_skills,
|
|
206
230
|
"artifact": f".devcouncil/runs/{run_id}/prompt_enhancement.json",
|
|
207
231
|
},
|
|
208
232
|
run_id=run_id,
|
|
@@ -210,6 +234,7 @@ async def run_plan_flow(
|
|
|
210
234
|
)
|
|
211
235
|
|
|
212
236
|
# 3. Spec / Requirements
|
|
237
|
+
log_step("plan/3: generating requirements", project_root=root, run_id=run_id)
|
|
213
238
|
progress.add_task(description="Generating requirements...", total=None)
|
|
214
239
|
spec_output = await spec_service.generate_spec(debate_goal, repo_map_json)
|
|
215
240
|
orchestrator.save_run_artifact("requirements.json", spec_output.model_dump())
|
|
@@ -219,49 +244,94 @@ async def run_plan_flow(
|
|
|
219
244
|
console.print(Panel(f"Found {len(spec_output.requirements)} requirements.", title="Requirements Generated"))
|
|
220
245
|
return []
|
|
221
246
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
247
|
+
requirements_json = json.dumps([r.model_dump() for r in spec_output.requirements])
|
|
248
|
+
|
|
249
|
+
if quick:
|
|
250
|
+
# Rigor dial: single pragmatic plan, no A/B debate, critique, rebuttal,
|
|
251
|
+
# or arbitration. Spec requirements (with their acceptance criteria)
|
|
252
|
+
# become the final requirements verbatim. This trades the council's
|
|
253
|
+
# adversarial robustness for ~5 fewer model calls — the right setting
|
|
254
|
+
# for small, well-scoped changes where verification (which still gates
|
|
255
|
+
# every diff) is the real safety net, not planning debate.
|
|
256
|
+
log_step("plan/4: generating single plan (quick mode)", project_root=root, run_id=run_id)
|
|
257
|
+
progress.add_task(description="Generating single plan (quick mode)...", total=None)
|
|
258
|
+
plan_a = await plan_service.generate_plan(
|
|
259
|
+
"planner_a", debate_goal, requirements_json, repo_map_json
|
|
260
|
+
)
|
|
261
|
+
orchestrator.save_run_artifact("plan_a.json", plan_a.model_dump())
|
|
262
|
+
await orchestrator.transition_to(ProjectPhase.PLANS_GENERATED)
|
|
263
|
+
|
|
264
|
+
decision = ArbiterDecision(
|
|
265
|
+
accepted_finding_ids=[],
|
|
266
|
+
rejected_finding_ids=[],
|
|
267
|
+
final_requirements=spec_output.requirements,
|
|
268
|
+
final_tasks=plan_a.tasks,
|
|
269
|
+
)
|
|
270
|
+
orchestrator.save_run_artifact("decision.json", decision.model_dump())
|
|
271
|
+
# Walk through CRITIQUES_GENERATED (the only path to ARBITRATED) without
|
|
272
|
+
# actually critiquing, so the rest of the lifecycle (approval, gates,
|
|
273
|
+
# status, the report's phase) is identical to the full council flow.
|
|
274
|
+
await orchestrator.transition_to(ProjectPhase.CRITIQUES_GENERATED)
|
|
275
|
+
await orchestrator.transition_to(ProjectPhase.ARBITRATED)
|
|
276
|
+
reconciled_findings = []
|
|
277
|
+
final_tasks = [task.model_copy(update={"status": "planned"}) for task in decision.final_tasks]
|
|
278
|
+
else:
|
|
279
|
+
# 4. Independent Plans (run concurrently — they don't depend on each other)
|
|
280
|
+
log_step("plan/4: generating Plans A (pragmatic) and B (robust)", project_root=root, run_id=run_id)
|
|
281
|
+
progress.add_task(description="Generating Plans A (Pragmatic) and B (Robust)...", total=None)
|
|
282
|
+
plan_a, plan_b = await asyncio.gather(
|
|
283
|
+
plan_service.generate_plan("planner_a", debate_goal, requirements_json, repo_map_json),
|
|
284
|
+
plan_service.generate_plan("planner_b", debate_goal, requirements_json, repo_map_json),
|
|
285
|
+
)
|
|
286
|
+
orchestrator.save_run_artifact("plan_a.json", plan_a.model_dump())
|
|
287
|
+
orchestrator.save_run_artifact("plan_b.json", plan_b.model_dump())
|
|
288
|
+
await orchestrator.transition_to(ProjectPhase.PLANS_GENERATED)
|
|
289
|
+
|
|
290
|
+
# Serialize each plan/critique once and reuse the strings across the
|
|
291
|
+
# critique, rebuttal, and arbitration steps below.
|
|
292
|
+
plan_a_json = plan_a.model_dump_json()
|
|
293
|
+
plan_b_json = plan_b.model_dump_json()
|
|
294
|
+
|
|
295
|
+
# 5. Cross-Critique (independent — run concurrently)
|
|
296
|
+
log_step("plan/5: cross-critiquing Plans A and B", project_root=root, run_id=run_id)
|
|
297
|
+
progress.add_task(description="Critiquing Plans A and B...", total=None)
|
|
298
|
+
critique_a, critique_b = await asyncio.gather(
|
|
299
|
+
critique_service.generate_critique("critic_a", plan_b_json, requirements_json),
|
|
300
|
+
critique_service.generate_critique("critic_b", plan_a_json, requirements_json),
|
|
301
|
+
)
|
|
302
|
+
orchestrator.save_run_artifact("critique_a.json", critique_a.model_dump())
|
|
303
|
+
orchestrator.save_run_artifact("critique_b.json", critique_b.model_dump())
|
|
304
|
+
await orchestrator.transition_to(ProjectPhase.CRITIQUES_GENERATED)
|
|
305
|
+
critique_a_json = critique_a.model_dump_json()
|
|
306
|
+
critique_b_json = critique_b.model_dump_json()
|
|
307
|
+
|
|
308
|
+
# 6. Rebuttals (independent — run concurrently)
|
|
309
|
+
log_step("plan/6: generating rebuttals", project_root=root, run_id=run_id)
|
|
310
|
+
progress.add_task(description="Generating rebuttals...", total=None)
|
|
311
|
+
rebuttal_a, rebuttal_b = await asyncio.gather(
|
|
312
|
+
critique_service.generate_rebuttal("planner_a", plan_a_json, critique_b_json),
|
|
313
|
+
critique_service.generate_rebuttal("planner_b", plan_b_json, critique_a_json),
|
|
314
|
+
)
|
|
315
|
+
orchestrator.save_run_artifact("rebuttal_a.json", rebuttal_a.model_dump())
|
|
316
|
+
orchestrator.save_run_artifact("rebuttal_b.json", rebuttal_b.model_dump())
|
|
317
|
+
|
|
318
|
+
# 7. Arbitration
|
|
319
|
+
log_step("plan/7: arbitrating final plan", project_root=root, run_id=run_id)
|
|
320
|
+
progress.add_task(description="Arbitrating final plan...", total=None)
|
|
321
|
+
decision = await arbiter_service.arbitrate(
|
|
322
|
+
debate_goal,
|
|
323
|
+
requirements_json,
|
|
324
|
+
plan_a_json,
|
|
325
|
+
plan_b_json,
|
|
326
|
+
critique_a_json,
|
|
327
|
+
critique_b_json,
|
|
328
|
+
rebuttal_a.model_dump_json(),
|
|
329
|
+
rebuttal_b.model_dump_json()
|
|
330
|
+
)
|
|
331
|
+
orchestrator.save_run_artifact("decision.json", decision.model_dump())
|
|
332
|
+
await orchestrator.transition_to(ProjectPhase.ARBITRATED)
|
|
333
|
+
reconciled_findings = _reconcile_findings([*critique_a.findings, *critique_b.findings], decision)
|
|
334
|
+
final_tasks = [task.model_copy(update={"status": "planned"}) for task in decision.final_tasks]
|
|
265
335
|
|
|
266
336
|
console.print("[green]Planning complete![/green]")
|
|
267
337
|
console.print(f"[blue]Prompt enhancement:[/blue] .devcouncil/runs/{run_id}/prompt_enhancement.json")
|
|
@@ -269,10 +339,23 @@ async def run_plan_flow(
|
|
|
269
339
|
console.print("[blue](DRY RUN: No actual LLM calls were made)[/blue]")
|
|
270
340
|
if not persist:
|
|
271
341
|
console.print("[blue](DRY RUN: Final requirements/tasks were not persisted)[/blue]")
|
|
342
|
+
# Operationalize the spec's edge-case elaboration: attach every acceptance criterion
|
|
343
|
+
# the planner left unlinked to a task that owns its requirement, so elaborated edges
|
|
344
|
+
# (truncation semantics, error paths, boundaries) are actually built and per-criterion
|
|
345
|
+
# verified instead of silently dropped — the gap that lets a gated run be no better
|
|
346
|
+
# than the raw prompt.
|
|
347
|
+
final_tasks, backfilled_acs = backfill_acceptance_criteria(final_tasks, decision.final_requirements)
|
|
348
|
+
if backfilled_acs:
|
|
349
|
+
console.print(
|
|
350
|
+
f"[dim]Linked {len(backfilled_acs)} unmapped acceptance criterion(s) to owning task(s) "
|
|
351
|
+
"so every elaborated behavior is verified.[/dim]"
|
|
352
|
+
)
|
|
353
|
+
|
|
272
354
|
console.print(f"Final Requirements: [bold]{len(decision.final_requirements)}[/bold]")
|
|
273
355
|
console.print(f"Final Tasks: [bold]{len(final_tasks)}[/bold]")
|
|
274
|
-
|
|
356
|
+
|
|
275
357
|
# 8. Check Gates
|
|
358
|
+
log_step("plan/8: checking plan-approval gates", project_root=root, run_id=run_id)
|
|
276
359
|
policy = GatePolicy()
|
|
277
360
|
result = policy.check_plan_approval(
|
|
278
361
|
decision.final_requirements,
|
|
@@ -294,8 +377,12 @@ async def run_plan_flow(
|
|
|
294
377
|
final_tasks,
|
|
295
378
|
reconciled_findings,
|
|
296
379
|
)
|
|
380
|
+
# Pin THIS plan's enhancement so the executor reads the domain guidance tied to
|
|
381
|
+
# the plan it runs (not a later run's by mtime).
|
|
382
|
+
save_active_prompt_enhancement(root, prompt_enhancement)
|
|
297
383
|
|
|
298
384
|
console.print("[green]Plan approved by gates.[/green]")
|
|
385
|
+
logger.info("Plan approved by gates: %d task(s)", len(final_tasks))
|
|
299
386
|
await orchestrator.transition_to(ProjectPhase.PLAN_APPROVED)
|
|
300
387
|
return [task.id for task in final_tasks]
|
|
301
388
|
else:
|
|
@@ -305,14 +392,107 @@ async def run_plan_flow(
|
|
|
305
392
|
for gap in result.gaps:
|
|
306
393
|
gap_repo.save(gap)
|
|
307
394
|
console.print("[yellow]Plan generated but failed gates. See status for gaps.[/yellow]")
|
|
395
|
+
logger.warning("Plan failed approval gates with %d gap(s)", len(result.gaps))
|
|
308
396
|
await orchestrator.transition_to(ProjectPhase.AWAITING_USER_DECISIONS)
|
|
309
397
|
return []
|
|
310
398
|
|
|
399
|
+
def _latest_run_with_decision(root: Path, run_id: str | None) -> Path | None:
|
|
400
|
+
runs_dir = root / ".devcouncil" / "runs"
|
|
401
|
+
if run_id:
|
|
402
|
+
candidate = runs_dir / run_id
|
|
403
|
+
return candidate if (candidate / "decision.json").exists() else None
|
|
404
|
+
if not runs_dir.exists():
|
|
405
|
+
return None
|
|
406
|
+
candidates = [d for d in runs_dir.iterdir() if (d / "decision.json").exists()]
|
|
407
|
+
if not candidates:
|
|
408
|
+
return None
|
|
409
|
+
return max(candidates, key=lambda d: (d / "decision.json").stat().st_mtime)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def approve(
|
|
413
|
+
run_id: str | None = typer.Option(None, "--run-id", help="Run whose generated plan to approve (defaults to the most recent run with a decision)."),
|
|
414
|
+
force: bool = typer.Option(False, "--force", help="Approve even if blocking gate gaps remain."),
|
|
415
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
416
|
+
):
|
|
417
|
+
"""
|
|
418
|
+
Approve a generated plan after reviewing gate gaps (AWAITING_USER_DECISIONS -> PLAN_APPROVED).
|
|
419
|
+
"""
|
|
420
|
+
from devcouncil.planning.arbiter_service import ArbiterDecision
|
|
421
|
+
from devcouncil.planning.critique_service import CritiqueOutput
|
|
422
|
+
from devcouncil.planning.spec_service import SpecOutput
|
|
423
|
+
|
|
424
|
+
root = project_root.expanduser().resolve()
|
|
425
|
+
db = get_db(root)
|
|
426
|
+
if not db:
|
|
427
|
+
console.print("[red]DevCouncil state is unavailable in this directory.[/red]")
|
|
428
|
+
raise typer.Exit(code=1)
|
|
429
|
+
|
|
430
|
+
run_dir = _latest_run_with_decision(root, run_id)
|
|
431
|
+
if run_dir is None:
|
|
432
|
+
console.print("[red]No planning run with a decision was found. Run 'dev plan' first.[/red]")
|
|
433
|
+
raise typer.Exit(code=1)
|
|
434
|
+
|
|
435
|
+
decision = ArbiterDecision.model_validate_json((run_dir / "decision.json").read_text(encoding="utf-8"))
|
|
436
|
+
spec_path = run_dir / "requirements.json"
|
|
437
|
+
spec_output = (
|
|
438
|
+
SpecOutput.model_validate_json(spec_path.read_text(encoding="utf-8")) if spec_path.exists() else None
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
findings = []
|
|
442
|
+
for name in ("critique_a.json", "critique_b.json"):
|
|
443
|
+
critique_path = run_dir / name
|
|
444
|
+
if critique_path.exists():
|
|
445
|
+
findings.extend(CritiqueOutput.model_validate_json(critique_path.read_text(encoding="utf-8")).findings)
|
|
446
|
+
reconciled_findings = _reconcile_findings(findings, decision)
|
|
447
|
+
final_tasks = [task.model_copy(update={"status": "planned"}) for task in decision.final_tasks]
|
|
448
|
+
assumptions = spec_output.assumptions if spec_output else []
|
|
449
|
+
|
|
450
|
+
policy = GatePolicy()
|
|
451
|
+
result = policy.check_plan_approval(
|
|
452
|
+
decision.final_requirements,
|
|
453
|
+
final_tasks,
|
|
454
|
+
assumptions=assumptions,
|
|
455
|
+
findings=reconciled_findings,
|
|
456
|
+
blocking_questions=spec_output.blocking_questions if spec_output else [],
|
|
457
|
+
)
|
|
458
|
+
if not result.passed and not force:
|
|
459
|
+
console.print("[yellow]Plan still fails approval gates:[/yellow]")
|
|
460
|
+
for gap in result.gaps:
|
|
461
|
+
marker = "[red][BLOCKING][/red] " if gap.blocking else ""
|
|
462
|
+
console.print(f" - {marker}{gap.description} (Fix: {gap.recommended_fix})")
|
|
463
|
+
console.print("Resolve the gaps and re-run 'dev plan', or use --force to approve anyway.")
|
|
464
|
+
raise typer.Exit(code=1)
|
|
465
|
+
|
|
466
|
+
with db.get_session() as session:
|
|
467
|
+
GapRepository(session).delete_plan_gaps()
|
|
468
|
+
PlanningStateRepository(session).replace_active_plan(
|
|
469
|
+
decision.final_requirements,
|
|
470
|
+
assumptions,
|
|
471
|
+
final_tasks,
|
|
472
|
+
reconciled_findings,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
orchestrator = Orchestrator(root)
|
|
476
|
+
try:
|
|
477
|
+
asyncio.run(orchestrator.transition_to(ProjectPhase.PLAN_APPROVED))
|
|
478
|
+
except ValueError as exc:
|
|
479
|
+
console.print(f"[red]Cannot approve from the current project phase: {exc}[/red]")
|
|
480
|
+
raise typer.Exit(code=1)
|
|
481
|
+
console.print(f"[green]Plan from run {run_dir.name} approved ({len(final_tasks)} tasks).[/green]")
|
|
482
|
+
console.print("Use 'dev tasks list' to see the planned tasks and 'dev run TASK-ID' to execute one.")
|
|
483
|
+
|
|
484
|
+
|
|
311
485
|
@app.command()
|
|
312
486
|
def plan(
|
|
313
487
|
goal: str = typer.Argument(..., help="The goal of the implementation"),
|
|
314
488
|
requirements_only: bool = typer.Option(False, "--requirements-only", help="Only generate requirements"),
|
|
315
489
|
dry_run: bool = typer.Option(False, "--dry-run", help="Simulate planning without LLM calls"),
|
|
490
|
+
quick: bool = typer.Option(
|
|
491
|
+
False,
|
|
492
|
+
"--quick",
|
|
493
|
+
help="Rigor dial: skip the A/B debate, critique, rebuttal, and arbitration. "
|
|
494
|
+
"One spec + one plan (~5 fewer model calls). Verification still gates every diff.",
|
|
495
|
+
),
|
|
316
496
|
persist: bool = typer.Option(
|
|
317
497
|
False,
|
|
318
498
|
"--persist/--no-persist",
|
|
@@ -324,4 +504,25 @@ def plan(
|
|
|
324
504
|
Run the full planning cycle (Repo map -> Spec -> Plan A/B -> Critique -> Arbiter).
|
|
325
505
|
"""
|
|
326
506
|
should_persist = persist or not dry_run
|
|
327
|
-
|
|
507
|
+
try:
|
|
508
|
+
asyncio.run(run_plan_flow(goal, requirements_only, dry_run, should_persist, project_root, quick=quick))
|
|
509
|
+
except (ProviderRequestError, StructuredOutputError) as exc:
|
|
510
|
+
print_planning_error(exc)
|
|
511
|
+
raise typer.Exit(code=1)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def print_planning_error(exc: Exception) -> None:
|
|
515
|
+
"""Render a planning/model failure as an actionable message instead of a traceback."""
|
|
516
|
+
console.print(f"\n[red]Planning could not complete:[/red] {exc}")
|
|
517
|
+
if isinstance(exc, StructuredOutputError):
|
|
518
|
+
console.print(
|
|
519
|
+
"[yellow]Tip:[/yellow] this role's model could not return valid structured JSON. "
|
|
520
|
+
"Free/very small models often can't. Set a more capable model, e.g.\n"
|
|
521
|
+
f" [bold]dev config models --role {exc.role} --model anthropic/claude-sonnet-4.6[/bold]\n"
|
|
522
|
+
" (or set all roles: [bold]dev config models --model <model>[/bold])"
|
|
523
|
+
)
|
|
524
|
+
elif isinstance(exc, ProviderRequestError) and exc.status_code == 402:
|
|
525
|
+
console.print(
|
|
526
|
+
"[yellow]Tip:[/yellow] add credits at https://openrouter.ai/settings/credits, "
|
|
527
|
+
"or switch to a free/cheaper model with [bold]dev config models --model <model>[/bold]."
|
|
528
|
+
)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import NoReturn
|
|
1
3
|
import typer
|
|
2
4
|
from rich.console import Console
|
|
3
5
|
from rich.markdown import Markdown
|
|
@@ -16,6 +18,7 @@ def prompt(
|
|
|
16
18
|
ctx: typer.Context,
|
|
17
19
|
task_id: str = typer.Argument(..., help="ID of the task to generate a prompt for"),
|
|
18
20
|
pretty: bool = typer.Option(False, "--pretty", help="Render the prompt for terminal reading."),
|
|
21
|
+
json_format: bool = typer.Option(False, "--json", help="Output machine-readable JSON: {ok, task_id, prompt}."),
|
|
19
22
|
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
20
23
|
):
|
|
21
24
|
"""
|
|
@@ -24,27 +27,35 @@ def prompt(
|
|
|
24
27
|
if ctx.invoked_subcommand is not None:
|
|
25
28
|
return
|
|
26
29
|
|
|
30
|
+
def _fail(message: str) -> NoReturn:
|
|
31
|
+
if json_format:
|
|
32
|
+
typer.echo(json.dumps({"ok": False, "task_id": task_id, "error": message}, indent=2))
|
|
33
|
+
else:
|
|
34
|
+
console.print(f"[red]{message}[/red]")
|
|
35
|
+
raise typer.Exit(code=1)
|
|
36
|
+
|
|
27
37
|
root = project_root.expanduser().resolve()
|
|
28
38
|
initialize_project(root, quiet=True)
|
|
29
39
|
db = get_db(root)
|
|
30
40
|
if not db:
|
|
31
|
-
|
|
41
|
+
_fail("DevCouncil state is unavailable in this directory.")
|
|
32
42
|
|
|
33
43
|
with db.get_session() as session:
|
|
34
44
|
task_repo = TaskRepository(session)
|
|
35
45
|
req_repo = RequirementRepository(session)
|
|
36
|
-
|
|
46
|
+
|
|
37
47
|
task = task_repo.get_by_id(task_id)
|
|
38
48
|
if not task:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
_fail(f"Task {task_id} not found.")
|
|
50
|
+
|
|
42
51
|
reqs = req_repo.get_all()
|
|
43
|
-
|
|
52
|
+
|
|
44
53
|
builder = PromptBuilder(root)
|
|
45
54
|
task_prompt = builder.build_task_prompt(task, reqs)
|
|
46
55
|
|
|
47
|
-
if
|
|
56
|
+
if json_format:
|
|
57
|
+
typer.echo(json.dumps({"ok": True, "task_id": task_id, "prompt": task_prompt}, indent=2))
|
|
58
|
+
elif pretty:
|
|
48
59
|
console.print(Markdown(task_prompt))
|
|
49
60
|
else:
|
|
50
61
|
typer.echo(task_prompt, nl=not task_prompt.endswith("\n"))
|
|
@@ -9,14 +9,19 @@ from devcouncil.llm.router import ModelRouter
|
|
|
9
9
|
from devcouncil.app.config import load_config, get_api_key
|
|
10
10
|
from devcouncil.cli.commands.init import initialize_project
|
|
11
11
|
import asyncio
|
|
12
|
+
import logging
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
14
15
|
app = typer.Typer()
|
|
15
16
|
console = Console()
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
16
18
|
|
|
17
|
-
async def run_repair_flow():
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
async def run_repair_flow(project_root: Path = Path(".")):
|
|
20
|
+
root = project_root.expanduser().resolve()
|
|
21
|
+
from devcouncil.telemetry.logging_setup import set_log_dir
|
|
22
|
+
set_log_dir(root)
|
|
23
|
+
initialize_project(root, quiet=True)
|
|
24
|
+
db = get_db(root)
|
|
20
25
|
if not db:
|
|
21
26
|
console.print("[red]DevCouncil state is unavailable in this directory.[/red]")
|
|
22
27
|
return
|
|
@@ -27,46 +32,67 @@ async def run_repair_flow():
|
|
|
27
32
|
|
|
28
33
|
all_gaps = gap_repo.get_all()
|
|
29
34
|
blocking_gaps = [g for g in all_gaps if g.blocking]
|
|
30
|
-
|
|
35
|
+
|
|
31
36
|
if not blocking_gaps:
|
|
37
|
+
logger.info("dev repair: no blocking gaps; nothing to repair")
|
|
32
38
|
console.print("[green]No blocking gaps found. Nothing to repair![/green]")
|
|
33
39
|
return
|
|
34
40
|
|
|
41
|
+
logger.info("dev repair: %d blocking gap(s) across %d task(s)", len(blocking_gaps), len({g.task_id for g in blocking_gaps if g.task_id}))
|
|
35
42
|
console.print(f"Found [bold]{len(blocking_gaps)}[/bold] blocking gaps. Orchestrating repair plan...")
|
|
36
43
|
|
|
37
|
-
# Load router
|
|
44
|
+
# Load router when credentials are available. Correction manifests have a
|
|
45
|
+
# deterministic fallback path, so missing model credentials must not block
|
|
46
|
+
# repair artifact generation.
|
|
47
|
+
repair_service = None
|
|
48
|
+
config = None
|
|
38
49
|
try:
|
|
39
|
-
config = load_config(
|
|
50
|
+
config = load_config(root)
|
|
40
51
|
validate_model_provider(config.models.provider)
|
|
41
|
-
api_key = get_api_key(config.models.provider,
|
|
52
|
+
api_key = get_api_key(config.models.provider, root)
|
|
42
53
|
except (FileNotFoundError, ValueError) as e:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
context_builder = ContextBuilder(
|
|
54
|
+
logger.warning("dev repair: no LLM provider (%s); using deterministic manifest fallback", e)
|
|
55
|
+
console.print(f"[yellow]{e}[/yellow]")
|
|
56
|
+
else:
|
|
57
|
+
provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
|
|
58
|
+
role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
|
|
59
|
+
router = ModelRouter(provider, role_config, project_root=root)
|
|
60
|
+
repair_service = RepairService(router)
|
|
61
|
+
context_builder = ContextBuilder(root)
|
|
51
62
|
|
|
52
63
|
# Build minimal context for repair.
|
|
53
64
|
project_context = context_builder.get_structure_summary()
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
for
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
from devcouncil.planning.correction_manifest import write_correction_manifest
|
|
67
|
+
|
|
68
|
+
task_ids = {gap.task_id for gap in blocking_gaps if gap.task_id}
|
|
69
|
+
for scoped_task_id in task_ids:
|
|
70
|
+
if scoped_task_id:
|
|
71
|
+
path = write_correction_manifest(root, scoped_task_id, repair_service=repair_service, config=config)
|
|
72
|
+
if path:
|
|
73
|
+
console.print(f" - Wrote correction manifest [dim]{path}[/dim]")
|
|
74
|
+
|
|
75
|
+
repair_count = 0
|
|
76
|
+
if repair_service is not None:
|
|
77
|
+
repair_output = await repair_service.generate_repair_plan(blocking_gaps, str(project_context))
|
|
78
|
+
for task in repair_output.suggested_tasks:
|
|
79
|
+
task.id = f"REPAIR-{task.id}"
|
|
80
|
+
task_repo.save(task)
|
|
81
|
+
repair_count += 1
|
|
82
|
+
console.print(f" - Created intelligent repair task [bold]{task.id}[/bold]: {task.title}")
|
|
61
83
|
|
|
62
|
-
|
|
84
|
+
logger.info("dev repair complete: generated %d repair task(s)", repair_count)
|
|
85
|
+
console.print(f"\n[green]Successfully generated {repair_count} repair tasks.[/green]")
|
|
63
86
|
|
|
64
87
|
@app.callback(invoke_without_command=True)
|
|
65
|
-
def repair(
|
|
88
|
+
def repair(
|
|
89
|
+
ctx: typer.Context,
|
|
90
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
91
|
+
):
|
|
66
92
|
"""
|
|
67
93
|
Convert blocking gaps into intelligent repair tasks using LLM inference.
|
|
68
94
|
"""
|
|
69
95
|
if ctx.invoked_subcommand is not None:
|
|
70
96
|
return
|
|
71
97
|
|
|
72
|
-
asyncio.run(run_repair_flow())
|
|
98
|
+
asyncio.run(run_repair_flow(project_root))
|
|
@@ -78,6 +78,11 @@ def report(
|
|
|
78
78
|
github: bool = typer.Option(False, "--github", help="Post report to GitHub PR Checks"),
|
|
79
79
|
github_pr_comment: bool = typer.Option(False, "--github-pr-comment", help="Post report as a GitHub PR comment"),
|
|
80
80
|
gitlab_pr_comment: bool = typer.Option(False, "--gitlab-pr-comment", help="Post report as a GitLab merge request comment"),
|
|
81
|
+
fail_on_blocking: bool = typer.Option(
|
|
82
|
+
False,
|
|
83
|
+
"--fail-on-blocking",
|
|
84
|
+
help="Exit non-zero when blocking gaps remain, so shell-driven agents can gate on $?.",
|
|
85
|
+
),
|
|
81
86
|
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
82
87
|
):
|
|
83
88
|
"""
|
|
@@ -127,3 +132,6 @@ def report(
|
|
|
127
132
|
else:
|
|
128
133
|
output = ReportBuilder.build_markdown(graph, live_review=live_review)
|
|
129
134
|
console.print(Markdown(output))
|
|
135
|
+
|
|
136
|
+
if fail_on_blocking and graph.blocking_gaps():
|
|
137
|
+
raise typer.Exit(code=1)
|