devcouncil 0.1.1 → 0.2.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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -5,13 +5,85 @@ import typer
5
5
  from rich.console import Console
6
6
 
7
7
  from devcouncil.cli.commands.init import initialize_project
8
- from devcouncil.indexing.repo_mapper import RepoMapper
8
+ from devcouncil.indexing.repo_mapper import RepoMap, RepoMapper
9
9
  from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
10
10
  from devcouncil.storage.db import get_db
11
11
 
12
12
  console = Console()
13
13
  status_console = Console(stderr=True)
14
14
 
15
+ AGENT_GUIDE_MARKER = "<!-- Managed by dev map: keep this file in sync with .devcouncil/repo_map.json. -->"
16
+
17
+
18
+ def _important_surfaces(repo_map: RepoMap) -> list[str]:
19
+ """Derive the 'important surfaces' list from the computed map, so the guide points
20
+ at THIS repo's real subsystems instead of hardcoded DevCouncil paths."""
21
+ lines: list[str] = []
22
+ for index, subsystem in enumerate(repo_map.subsystems[:6], start=1):
23
+ lines.append(f"{index}. `{subsystem.area}/` — {subsystem.summary}")
24
+ if not lines:
25
+ for index, path in enumerate(repo_map.important_files[:6], start=1):
26
+ lines.append(f"{index}. `{path}`")
27
+ return lines or ["1. See `.devcouncil/repo_map.json` for the file index."]
28
+
29
+
30
+ def _agent_guide_text(repo_map_path: Path, repo_root: Path, repo_map: RepoMap) -> str:
31
+ return "\n".join(
32
+ [
33
+ AGENT_GUIDE_MARKER,
34
+ "",
35
+ "# Agent Workspace Guide",
36
+ "",
37
+ "Use `.devcouncil/repo_map.json` as the primary file index for this workspace.",
38
+ f"Repo map: `{repo_map_path.relative_to(repo_root).as_posix() if repo_map_path.is_relative_to(repo_root) else repo_map_path}`",
39
+ "",
40
+ "Workflow for agents:",
41
+ "1. Open `.devcouncil/repo_map.json` before guessing at file locations.",
42
+ "2. Use the `files` list to resolve module ownership and nearby siblings.",
43
+ "3. Use `subsystems` for subsystem-level navigation.",
44
+ "4. In `subsystems`, use `entry_points` + `critical_files` for entry points and starting context.",
45
+ "5. Use `role_files` in `subsystems` for subsystem role buckets (entry, runtime, policy, adapters, etc.).",
46
+ "6. Use `neighbors` and `handoff_paths` in `subsystems` to follow cross-subsystem flow.",
47
+ "7. Run `dev map` again after large refactors to refresh the map.",
48
+ "",
49
+ "Important surfaces:",
50
+ *_important_surfaces(repo_map),
51
+ "",
52
+ "If the map and source disagree, trust the source and regenerate the map.",
53
+ ]
54
+ )
55
+
56
+
57
+ def _write_agent_guides(repo_root: Path, repo_map_path: Path, repo_map: RepoMap) -> None:
58
+ for filename in ("AGENTS.md", "CLAUDE.md"):
59
+ path = repo_root / filename
60
+ if path.exists():
61
+ existing = path.read_text(encoding="utf-8")
62
+ if AGENT_GUIDE_MARKER not in existing:
63
+ continue
64
+ path.write_text(_agent_guide_text(repo_map_path, repo_root, repo_map) + "\n", encoding="utf-8")
65
+
66
+
67
+ def generate_map_artifacts(root: Path, output: Path, goal: str = "", *, scan_dependencies: bool = False) -> RepoMap:
68
+ """Build the repo map and write repo_map.json + agent guides (no LLM, no re-init).
69
+
70
+ Assumes ``.devcouncil/`` already exists. Shared by the ``dev map`` command and
71
+ by project initialization so a freshly set-up repo is immediately navigable.
72
+ ``scan_dependencies`` is opt-in (off for init and default mapping) because it can
73
+ shell out to dependency auditors.
74
+ """
75
+ repo_map = RepoMapper(root).map_repo(goal, scan_dependencies=scan_dependencies)
76
+ graph_context = CodeReviewGraphAdapter(root).get_context()
77
+ output = output if output.is_absolute() else root / output
78
+ output.parent.mkdir(parents=True, exist_ok=True)
79
+ output.write_text(repo_map.model_dump_json(indent=2), encoding="utf-8")
80
+ _write_agent_guides(root, output, repo_map)
81
+ if graph_context.available:
82
+ graph_output = output.with_name("code_review_graph_context.json")
83
+ graph_output.write_text(graph_context.model_dump_json(indent=2), encoding="utf-8")
84
+ status_console.print(f"[green]Wrote code-review-graph context to {graph_output}[/green]")
85
+ return repo_map
86
+
15
87
 
16
88
  def map_repo(
17
89
  goal: str = typer.Argument("", help="Goal text used for candidate-file ranking."),
@@ -22,21 +94,19 @@ def map_repo(
22
94
  help="Path to write repo_map.json.",
23
95
  ),
24
96
  project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
97
+ scan_deps: bool = typer.Option(
98
+ False,
99
+ "--scan-deps",
100
+ help="Run available dependency auditors (pip-audit/npm audit/osv-scanner) and record dependency_risks in the map. Off by default.",
101
+ ),
25
102
  ):
26
103
  """Build the deterministic repository map without calling an LLM."""
27
104
  root = project_root.expanduser().resolve()
28
- initialize_project(root, quiet=True)
105
+ initialize_project(root, quiet=True, with_map=False)
29
106
  if not get_db(root):
30
107
  raise typer.Exit(code=1)
31
108
 
32
- repo_map = RepoMapper(root).map_repo(goal)
33
- graph_context = CodeReviewGraphAdapter(root).get_context()
34
109
  output = output if output.is_absolute() else root / output
35
- output.parent.mkdir(parents=True, exist_ok=True)
36
- output.write_text(repo_map.model_dump_json(indent=2), encoding="utf-8")
37
- if graph_context.available:
38
- graph_output = output.with_name("code_review_graph_context.json")
39
- graph_output.write_text(graph_context.model_dump_json(indent=2), encoding="utf-8")
40
- status_console.print(f"[green]Wrote code-review-graph context to {graph_output}[/green]")
110
+ repo_map = generate_map_artifacts(root, output, goal, scan_dependencies=scan_deps)
41
111
  typer.echo(json.dumps(repo_map.model_dump(), indent=2))
42
112
  status_console.print(f"[green]Wrote repository map to {output}[/green]")
@@ -15,13 +15,13 @@ from devcouncil.storage.repositories import (
15
15
  )
16
16
  from devcouncil.indexing.repo_mapper import RepoMapper
17
17
  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
18
+ from devcouncil.llm.provider import Provider, MockProvider, ProviderRequestError, build_role_model_config, create_provider, validate_model_provider
19
+ from devcouncil.llm.router import ModelRouter, StructuredOutputError
20
20
  from devcouncil.planning.spec_service import SpecService
21
21
  from devcouncil.planning.prompt_enhancer_service import PromptEnhancerService
22
22
  from devcouncil.planning.plan_service import PlanService
23
23
  from devcouncil.planning.critique_service import CritiqueService
24
- from devcouncil.planning.arbiter_service import ArbiterService
24
+ from devcouncil.planning.arbiter_service import ArbiterDecision, ArbiterService
25
25
  from devcouncil.gating.policy import GatePolicy
26
26
  from devcouncil.app.orchestrator import Orchestrator
27
27
  from devcouncil.app.state_machine import ProjectPhase
@@ -32,7 +32,6 @@ from devcouncil.telemetry.traces import TraceLogger
32
32
  app = typer.Typer()
33
33
  console = Console()
34
34
 
35
- DEFAULT_PLANNING_MODEL = "anthropic/claude-3.5-sonnet"
36
35
  REQUIRED_PLANNING_ROLES = (
37
36
  "prompt_enhancer",
38
37
  "spec_writer",
@@ -75,7 +74,11 @@ def _ensure_planning_roles(config) -> None:
75
74
  if fallback is None and config.models.roles:
76
75
  fallback = next(iter(config.models.roles.values()))
77
76
  if fallback is None:
78
- fallback = ModelRoleConfig(model=DEFAULT_PLANNING_MODEL)
77
+ try:
78
+ provider_roles = build_role_model_config(config.models.provider)
79
+ fallback = ModelRoleConfig(model=provider_roles["spec_writer"]["model"])
80
+ except ValueError:
81
+ fallback = ModelRoleConfig(model="unconfigured")
79
82
 
80
83
  for role in REQUIRED_PLANNING_ROLES:
81
84
  config.models.roles.setdefault(role, fallback.model_copy())
@@ -86,6 +89,7 @@ async def run_plan_flow(
86
89
  dry_run: bool = False,
87
90
  persist: bool = True,
88
91
  project_root: Path = Path("."),
92
+ quick: bool = False,
89
93
  ):
90
94
  root = project_root.expanduser().resolve()
91
95
  initialize_project(root, quiet=True)
@@ -112,6 +116,7 @@ async def run_plan_flow(
112
116
  run_id = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-plan"
113
117
  await orchestrator.start_run(run_id, goal)
114
118
 
119
+ provider: Provider
115
120
  if dry_run:
116
121
  # Override config models to be unique roles for mock mapping
117
122
  for role in REQUIRED_PLANNING_ROLES:
@@ -158,11 +163,14 @@ async def run_plan_flow(
158
163
  # I'll modify PlanService to use a slightly different role string if needed,
159
164
  # but for Dry Run, let's just make the MockProvider return based on the schema requested.
160
165
  else:
161
- provider = create_provider(config.models.provider, api_key)
166
+ if api_key is None:
167
+ console.print("[red]Missing API key for configured model provider.[/red]")
168
+ return []
169
+ provider = create_provider(config.models.provider, api_key, project_root=root)
162
170
 
163
171
  # Build role config after dry-run overrides so mocks are routed correctly.
164
172
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
165
- router = ModelRouter(provider, role_config)
173
+ router = ModelRouter(provider, role_config, project_root=root)
166
174
 
167
175
  prompt_enhancer = PromptEnhancerService(router)
168
176
  spec_service = SpecService(router)
@@ -192,9 +200,15 @@ async def run_plan_flow(
192
200
  goal,
193
201
  repo_map_json,
194
202
  graph_context.model_dump_json() if graph_context.available else None,
203
+ project_root=root,
195
204
  )
196
205
  debate_goal = prompt_enhancement.debate_prompt()
197
206
  orchestrator.save_run_artifact("prompt_enhancement.json", prompt_enhancement.model_dump())
207
+ if prompt_enhancement.applied_skills:
208
+ console.print(
209
+ "[dim]Domain skills applied:[/dim] "
210
+ + ", ".join(prompt_enhancement.applied_skills)
211
+ )
198
212
  TraceLogger(root).log_event(
199
213
  "prompt_enhanced",
200
214
  {
@@ -203,6 +217,7 @@ async def run_plan_flow(
203
217
  "codebase_context_count": len(prompt_enhancement.codebase_context),
204
218
  "constraint_count": len(prompt_enhancement.constraints),
205
219
  "debate_focus_count": len(prompt_enhancement.debate_focus),
220
+ "applied_skills": prompt_enhancement.applied_skills,
206
221
  "artifact": f".devcouncil/runs/{run_id}/prompt_enhancement.json",
207
222
  },
208
223
  run_id=run_id,
@@ -219,49 +234,82 @@ async def run_plan_flow(
219
234
  console.print(Panel(f"Found {len(spec_output.requirements)} requirements.", title="Requirements Generated"))
220
235
  return []
221
236
 
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]
237
+ requirements_json = json.dumps([r.model_dump() for r in spec_output.requirements])
238
+
239
+ if quick:
240
+ # Rigor dial: single pragmatic plan, no A/B debate, critique, rebuttal,
241
+ # or arbitration. Spec requirements (with their acceptance criteria)
242
+ # become the final requirements verbatim. This trades the council's
243
+ # adversarial robustness for ~5 fewer model calls the right setting
244
+ # for small, well-scoped changes where verification (which still gates
245
+ # every diff) is the real safety net, not planning debate.
246
+ progress.add_task(description="Generating single plan (quick mode)...", total=None)
247
+ plan_a = await plan_service.generate_plan(
248
+ "planner_a", debate_goal, requirements_json, repo_map_json
249
+ )
250
+ orchestrator.save_run_artifact("plan_a.json", plan_a.model_dump())
251
+ await orchestrator.transition_to(ProjectPhase.PLANS_GENERATED)
252
+
253
+ decision = ArbiterDecision(
254
+ accepted_finding_ids=[],
255
+ rejected_finding_ids=[],
256
+ final_requirements=spec_output.requirements,
257
+ final_tasks=plan_a.tasks,
258
+ )
259
+ orchestrator.save_run_artifact("decision.json", decision.model_dump())
260
+ # Walk through CRITIQUES_GENERATED (the only path to ARBITRATED) without
261
+ # actually critiquing, so the rest of the lifecycle (approval, gates,
262
+ # status, the report's phase) is identical to the full council flow.
263
+ await orchestrator.transition_to(ProjectPhase.CRITIQUES_GENERATED)
264
+ await orchestrator.transition_to(ProjectPhase.ARBITRATED)
265
+ reconciled_findings = []
266
+ final_tasks = [task.model_copy(update={"status": "planned"}) for task in decision.final_tasks]
267
+ else:
268
+ # 4. Independent Plans (run concurrently they don't depend on each other)
269
+ progress.add_task(description="Generating Plans A (Pragmatic) and B (Robust)...", total=None)
270
+ plan_a, plan_b = await asyncio.gather(
271
+ plan_service.generate_plan("planner_a", debate_goal, requirements_json, repo_map_json),
272
+ plan_service.generate_plan("planner_b", debate_goal, requirements_json, repo_map_json),
273
+ )
274
+ orchestrator.save_run_artifact("plan_a.json", plan_a.model_dump())
275
+ orchestrator.save_run_artifact("plan_b.json", plan_b.model_dump())
276
+ await orchestrator.transition_to(ProjectPhase.PLANS_GENERATED)
277
+
278
+ # 5. Cross-Critique (independent run concurrently)
279
+ progress.add_task(description="Critiquing Plans A and B...", total=None)
280
+ critique_a, critique_b = await asyncio.gather(
281
+ critique_service.generate_critique("critic_a", plan_b.model_dump_json(), requirements_json),
282
+ critique_service.generate_critique("critic_b", plan_a.model_dump_json(), requirements_json),
283
+ )
284
+ orchestrator.save_run_artifact("critique_a.json", critique_a.model_dump())
285
+ orchestrator.save_run_artifact("critique_b.json", critique_b.model_dump())
286
+ await orchestrator.transition_to(ProjectPhase.CRITIQUES_GENERATED)
287
+
288
+ # 6. Rebuttals (independent — run concurrently)
289
+ progress.add_task(description="Generating rebuttals...", total=None)
290
+ rebuttal_a, rebuttal_b = await asyncio.gather(
291
+ critique_service.generate_rebuttal("planner_a", plan_a.model_dump_json(), critique_b.model_dump_json()),
292
+ critique_service.generate_rebuttal("planner_b", plan_b.model_dump_json(), critique_a.model_dump_json()),
293
+ )
294
+ orchestrator.save_run_artifact("rebuttal_a.json", rebuttal_a.model_dump())
295
+ orchestrator.save_run_artifact("rebuttal_b.json", rebuttal_b.model_dump())
296
+
297
+ # 7. Arbitration
298
+ progress.add_task(description="Arbitrating final plan...", total=None)
299
+ decision = await arbiter_service.arbitrate(
300
+ debate_goal,
301
+ json.dumps([r.model_dump() for r in spec_output.requirements]),
302
+ plan_a.model_dump_json(),
303
+ plan_b.model_dump_json(),
304
+ critique_a.model_dump_json(),
305
+ critique_b.model_dump_json(),
306
+ rebuttal_a.model_dump_json(),
307
+ rebuttal_b.model_dump_json()
308
+ )
309
+ orchestrator.save_run_artifact("decision.json", decision.model_dump())
310
+ await orchestrator.transition_to(ProjectPhase.ARBITRATED)
311
+ reconciled_findings = _reconcile_findings([*critique_a.findings, *critique_b.findings], decision)
312
+ final_tasks = [task.model_copy(update={"status": "planned"}) for task in decision.final_tasks]
265
313
 
266
314
  console.print("[green]Planning complete![/green]")
267
315
  console.print(f"[blue]Prompt enhancement:[/blue] .devcouncil/runs/{run_id}/prompt_enhancement.json")
@@ -308,11 +356,103 @@ async def run_plan_flow(
308
356
  await orchestrator.transition_to(ProjectPhase.AWAITING_USER_DECISIONS)
309
357
  return []
310
358
 
359
+ def _latest_run_with_decision(root: Path, run_id: str | None) -> Path | None:
360
+ runs_dir = root / ".devcouncil" / "runs"
361
+ if run_id:
362
+ candidate = runs_dir / run_id
363
+ return candidate if (candidate / "decision.json").exists() else None
364
+ if not runs_dir.exists():
365
+ return None
366
+ candidates = [d for d in runs_dir.iterdir() if (d / "decision.json").exists()]
367
+ if not candidates:
368
+ return None
369
+ return max(candidates, key=lambda d: (d / "decision.json").stat().st_mtime)
370
+
371
+
372
+ def approve(
373
+ 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)."),
374
+ force: bool = typer.Option(False, "--force", help="Approve even if blocking gate gaps remain."),
375
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
376
+ ):
377
+ """
378
+ Approve a generated plan after reviewing gate gaps (AWAITING_USER_DECISIONS -> PLAN_APPROVED).
379
+ """
380
+ from devcouncil.planning.arbiter_service import ArbiterDecision
381
+ from devcouncil.planning.critique_service import CritiqueOutput
382
+ from devcouncil.planning.spec_service import SpecOutput
383
+
384
+ root = project_root.expanduser().resolve()
385
+ db = get_db(root)
386
+ if not db:
387
+ console.print("[red]DevCouncil state is unavailable in this directory.[/red]")
388
+ raise typer.Exit(code=1)
389
+
390
+ run_dir = _latest_run_with_decision(root, run_id)
391
+ if run_dir is None:
392
+ console.print("[red]No planning run with a decision was found. Run 'dev plan' first.[/red]")
393
+ raise typer.Exit(code=1)
394
+
395
+ decision = ArbiterDecision.model_validate_json((run_dir / "decision.json").read_text(encoding="utf-8"))
396
+ spec_path = run_dir / "requirements.json"
397
+ spec_output = (
398
+ SpecOutput.model_validate_json(spec_path.read_text(encoding="utf-8")) if spec_path.exists() else None
399
+ )
400
+
401
+ findings = []
402
+ for name in ("critique_a.json", "critique_b.json"):
403
+ critique_path = run_dir / name
404
+ if critique_path.exists():
405
+ findings.extend(CritiqueOutput.model_validate_json(critique_path.read_text(encoding="utf-8")).findings)
406
+ reconciled_findings = _reconcile_findings(findings, decision)
407
+ final_tasks = [task.model_copy(update={"status": "planned"}) for task in decision.final_tasks]
408
+ assumptions = spec_output.assumptions if spec_output else []
409
+
410
+ policy = GatePolicy()
411
+ result = policy.check_plan_approval(
412
+ decision.final_requirements,
413
+ final_tasks,
414
+ assumptions=assumptions,
415
+ findings=reconciled_findings,
416
+ blocking_questions=spec_output.blocking_questions if spec_output else [],
417
+ )
418
+ if not result.passed and not force:
419
+ console.print("[yellow]Plan still fails approval gates:[/yellow]")
420
+ for gap in result.gaps:
421
+ marker = "[red][BLOCKING][/red] " if gap.blocking else ""
422
+ console.print(f" - {marker}{gap.description} (Fix: {gap.recommended_fix})")
423
+ console.print("Resolve the gaps and re-run 'dev plan', or use --force to approve anyway.")
424
+ raise typer.Exit(code=1)
425
+
426
+ with db.get_session() as session:
427
+ GapRepository(session).delete_plan_gaps()
428
+ PlanningStateRepository(session).replace_active_plan(
429
+ decision.final_requirements,
430
+ assumptions,
431
+ final_tasks,
432
+ reconciled_findings,
433
+ )
434
+
435
+ orchestrator = Orchestrator(root)
436
+ try:
437
+ asyncio.run(orchestrator.transition_to(ProjectPhase.PLAN_APPROVED))
438
+ except ValueError as exc:
439
+ console.print(f"[red]Cannot approve from the current project phase: {exc}[/red]")
440
+ raise typer.Exit(code=1)
441
+ console.print(f"[green]Plan from run {run_dir.name} approved ({len(final_tasks)} tasks).[/green]")
442
+ console.print("Use 'dev tasks list' to see the planned tasks and 'dev run TASK-ID' to execute one.")
443
+
444
+
311
445
  @app.command()
312
446
  def plan(
313
447
  goal: str = typer.Argument(..., help="The goal of the implementation"),
314
448
  requirements_only: bool = typer.Option(False, "--requirements-only", help="Only generate requirements"),
315
449
  dry_run: bool = typer.Option(False, "--dry-run", help="Simulate planning without LLM calls"),
450
+ quick: bool = typer.Option(
451
+ False,
452
+ "--quick",
453
+ help="Rigor dial: skip the A/B debate, critique, rebuttal, and arbitration. "
454
+ "One spec + one plan (~5 fewer model calls). Verification still gates every diff.",
455
+ ),
316
456
  persist: bool = typer.Option(
317
457
  False,
318
458
  "--persist/--no-persist",
@@ -324,4 +464,25 @@ def plan(
324
464
  Run the full planning cycle (Repo map -> Spec -> Plan A/B -> Critique -> Arbiter).
325
465
  """
326
466
  should_persist = persist or not dry_run
327
- asyncio.run(run_plan_flow(goal, requirements_only, dry_run, should_persist, project_root))
467
+ try:
468
+ asyncio.run(run_plan_flow(goal, requirements_only, dry_run, should_persist, project_root, quick=quick))
469
+ except (ProviderRequestError, StructuredOutputError) as exc:
470
+ print_planning_error(exc)
471
+ raise typer.Exit(code=1)
472
+
473
+
474
+ def print_planning_error(exc: Exception) -> None:
475
+ """Render a planning/model failure as an actionable message instead of a traceback."""
476
+ console.print(f"\n[red]Planning could not complete:[/red] {exc}")
477
+ if isinstance(exc, StructuredOutputError):
478
+ console.print(
479
+ "[yellow]Tip:[/yellow] this role's model could not return valid structured JSON. "
480
+ "Free/very small models often can't. Set a more capable model, e.g.\n"
481
+ f" [bold]dev config models --role {exc.role} --model anthropic/claude-sonnet-4.6[/bold]\n"
482
+ " (or set all roles: [bold]dev config models --model <model>[/bold])"
483
+ )
484
+ elif isinstance(exc, ProviderRequestError) and exc.status_code == 402:
485
+ console.print(
486
+ "[yellow]Tip:[/yellow] add credits at https://openrouter.ai/settings/credits, "
487
+ "or switch to a free/cheaper model with [bold]dev config models --model <model>[/bold]."
488
+ )
@@ -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"))
@@ -14,9 +14,10 @@ from pathlib import Path
14
14
  app = typer.Typer()
15
15
  console = Console()
16
16
 
17
- async def run_repair_flow():
18
- initialize_project(Path("."), quiet=True)
19
- db = get_db()
17
+ async def run_repair_flow(project_root: Path = Path(".")):
18
+ root = project_root.expanduser().resolve()
19
+ initialize_project(root, quiet=True)
20
+ db = get_db(root)
20
21
  if not db:
21
22
  console.print("[red]DevCouncil state is unavailable in this directory.[/red]")
22
23
  return
@@ -34,39 +35,55 @@ async def run_repair_flow():
34
35
 
35
36
  console.print(f"Found [bold]{len(blocking_gaps)}[/bold] blocking gaps. Orchestrating repair plan...")
36
37
 
37
- # Load router
38
+ # Load router when credentials are available. Correction manifests have a
39
+ # deterministic fallback path, so missing model credentials must not block
40
+ # repair artifact generation.
41
+ repair_service = None
38
42
  try:
39
- config = load_config(Path("."))
43
+ config = load_config(root)
40
44
  validate_model_provider(config.models.provider)
41
- api_key = get_api_key(config.models.provider, Path("."))
45
+ api_key = get_api_key(config.models.provider, root)
42
46
  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("."))
47
+ console.print(f"[yellow]{e}[/yellow]")
48
+ else:
49
+ provider = create_provider(config.models.provider, api_key, project_root=root)
50
+ role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
51
+ router = ModelRouter(provider, role_config, project_root=root)
52
+ repair_service = RepairService(router)
53
+ context_builder = ContextBuilder(root)
51
54
 
52
55
  # Build minimal context for repair.
53
56
  project_context = context_builder.get_structure_summary()
54
57
 
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}")
58
+ from devcouncil.planning.correction_manifest import write_correction_manifest
59
+
60
+ task_ids = {gap.task_id for gap in blocking_gaps if gap.task_id}
61
+ for scoped_task_id in task_ids:
62
+ if scoped_task_id:
63
+ path = write_correction_manifest(root, scoped_task_id, repair_service=repair_service)
64
+ if path:
65
+ console.print(f" - Wrote correction manifest [dim]{path}[/dim]")
66
+
67
+ repair_count = 0
68
+ if repair_service is not None:
69
+ repair_output = await repair_service.generate_repair_plan(blocking_gaps, str(project_context))
70
+ for task in repair_output.suggested_tasks:
71
+ task.id = f"REPAIR-{task.id}"
72
+ task_repo.save(task)
73
+ repair_count += 1
74
+ console.print(f" - Created intelligent repair task [bold]{task.id}[/bold]: {task.title}")
61
75
 
62
- console.print(f"\n[green]Successfully generated {len(repair_output.suggested_tasks)} repair tasks.[/green]")
76
+ console.print(f"\n[green]Successfully generated {repair_count} repair tasks.[/green]")
63
77
 
64
78
  @app.callback(invoke_without_command=True)
65
- def repair(ctx: typer.Context):
79
+ def repair(
80
+ ctx: typer.Context,
81
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
82
+ ):
66
83
  """
67
84
  Convert blocking gaps into intelligent repair tasks using LLM inference.
68
85
  """
69
86
  if ctx.invoked_subcommand is not None:
70
87
  return
71
88
 
72
- asyncio.run(run_repair_flow())
89
+ 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)
@@ -12,14 +12,16 @@ console = Console()
12
12
 
13
13
  def reset_demo_state(
14
14
  yes: bool = typer.Option(False, "--yes", help="Confirm clearing planning/demo artifacts."),
15
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
15
16
  ):
16
17
  """Clear demo planning artifacts from the local DevCouncil state database."""
17
18
  if not yes:
18
19
  console.print("[red]Refusing to clear state without --yes.[/red]")
19
20
  raise typer.Exit(code=1)
20
21
 
21
- initialize_project(Path("."), quiet=True)
22
- db = get_db()
22
+ root = project_root.expanduser().resolve()
23
+ initialize_project(root, quiet=True)
24
+ db = get_db(root)
23
25
  if not db:
24
26
  console.print("[red]DevCouncil state is unavailable in this directory.[/red]")
25
27
  raise typer.Exit(code=1)