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
@@ -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
- fallback = ModelRoleConfig(model=DEFAULT_PLANNING_MODEL)
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
- provider = create_provider(config.models.provider, api_key)
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
- orchestrator.save_run_artifact("repo_map.json", json.loads(repo_map_json))
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
- # 4. Independent Plans
223
- progress.add_task(description="Generating Plan A (Pragmatic)...", total=None)
224
- plan_a = await plan_service.generate_plan("planner_a", debate_goal, json.dumps([r.model_dump() for r in spec_output.requirements]), repo_map_json)
225
- orchestrator.save_run_artifact("plan_a.json", plan_a.model_dump())
226
-
227
- progress.add_task(description="Generating Plan B (Robust)...", total=None)
228
- plan_b = await plan_service.generate_plan("planner_b", debate_goal, json.dumps([r.model_dump() for r in spec_output.requirements]), repo_map_json)
229
- orchestrator.save_run_artifact("plan_b.json", plan_b.model_dump())
230
- await orchestrator.transition_to(ProjectPhase.PLANS_GENERATED)
231
-
232
- # 5. Cross-Critique
233
- progress.add_task(description="Critiquing Plan B...", total=None)
234
- critique_a = await critique_service.generate_critique("critic_a", plan_b.model_dump_json(), json.dumps([r.model_dump() for r in spec_output.requirements]))
235
- orchestrator.save_run_artifact("critique_a.json", critique_a.model_dump())
236
-
237
- progress.add_task(description="Critiquing Plan A...", total=None)
238
- critique_b = await critique_service.generate_critique("critic_b", plan_a.model_dump_json(), json.dumps([r.model_dump() for r in spec_output.requirements]))
239
- orchestrator.save_run_artifact("critique_b.json", critique_b.model_dump())
240
- await orchestrator.transition_to(ProjectPhase.CRITIQUES_GENERATED)
241
-
242
- # 6. Rebuttals
243
- progress.add_task(description="Generating rebuttals...", total=None)
244
- rebuttal_a = await critique_service.generate_rebuttal("planner_a", plan_a.model_dump_json(), critique_b.model_dump_json())
245
- orchestrator.save_run_artifact("rebuttal_a.json", rebuttal_a.model_dump())
246
- rebuttal_b = await critique_service.generate_rebuttal("planner_b", plan_b.model_dump_json(), critique_a.model_dump_json())
247
- orchestrator.save_run_artifact("rebuttal_b.json", rebuttal_b.model_dump())
248
-
249
- # 7. Arbitration
250
- progress.add_task(description="Arbitrating final plan...", total=None)
251
- decision = await arbiter_service.arbitrate(
252
- debate_goal,
253
- json.dumps([r.model_dump() for r in spec_output.requirements]),
254
- plan_a.model_dump_json(),
255
- plan_b.model_dump_json(),
256
- critique_a.model_dump_json(),
257
- critique_b.model_dump_json(),
258
- rebuttal_a.model_dump_json(),
259
- rebuttal_b.model_dump_json()
260
- )
261
- orchestrator.save_run_artifact("decision.json", decision.model_dump())
262
- await orchestrator.transition_to(ProjectPhase.ARBITRATED)
263
- reconciled_findings = _reconcile_findings([*critique_a.findings, *critique_b.findings], decision)
264
- final_tasks = [task.model_copy(update={"status": "planned"}) for task in decision.final_tasks]
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
- asyncio.run(run_plan_flow(goal, requirements_only, dry_run, should_persist, project_root))
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
- raise typer.Exit(code=1)
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
- console.print(f"[red]Task {task_id} not found.[/red]")
40
- raise typer.Exit(code=1)
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 pretty:
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
- initialize_project(Path("."), quiet=True)
19
- db = get_db()
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(Path("."))
50
+ config = load_config(root)
40
51
  validate_model_provider(config.models.provider)
41
- api_key = get_api_key(config.models.provider, Path("."))
52
+ api_key = get_api_key(config.models.provider, root)
42
53
  except (FileNotFoundError, ValueError) as e:
43
- console.print(f"[red]{e}[/red]")
44
- return
45
-
46
- provider = create_provider(config.models.provider, api_key)
47
- role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
48
- router = ModelRouter(provider, role_config)
49
- repair_service = RepairService(router)
50
- context_builder = ContextBuilder(Path("."))
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
- repair_output = await repair_service.generate_repair_plan(blocking_gaps, str(project_context))
56
-
57
- for task in repair_output.suggested_tasks:
58
- task.id = f"REPAIR-{task.id}"
59
- task_repo.save(task)
60
- console.print(f" - Created intelligent repair task [bold]{task.id}[/bold]: {task.title}")
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
- console.print(f"\n[green]Successfully generated {len(repair_output.suggested_tasks)} repair tasks.[/green]")
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(ctx: typer.Context):
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)