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
@@ -1,36 +1,50 @@
1
1
  import asyncio
2
+ import hashlib
3
+ import logging
4
+ import subprocess
2
5
  from pathlib import Path
3
6
  from types import SimpleNamespace
4
7
 
5
8
  import typer
6
9
  from rich.console import Console
7
10
 
11
+ from devcouncil.telemetry.stages import log_stage, log_step
12
+ from devcouncil.telemetry.logging_setup import set_log_dir
13
+
14
+ from devcouncil.app.config import load_config
8
15
  from devcouncil.cli.commands import plan as plan_command
9
16
  from devcouncil.cli.commands import report as report_command
10
17
  from devcouncil.cli.commands import run as run_command
11
- from devcouncil.app.config import load_config
18
+ from devcouncil.cli.commands import verify as verify_command
12
19
  from devcouncil.cli.commands.init import initialize_project
20
+ from devcouncil.executors.agent_registry import (
21
+ AGENT_ALIASES,
22
+ BUILTIN_CODING_EXECUTOR_NAMES,
23
+ load_cli_agent_specs,
24
+ resolve_automated_executor,
25
+ )
26
+ from devcouncil.integrations.github_intent import resolve_goal_intent
27
+ from devcouncil.llm.provider import ProviderRequestError
28
+ from devcouncil.llm.router import StructuredOutputError
13
29
  from devcouncil.storage.db import get_db
14
- from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository, TaskRepository
30
+ from devcouncil.storage.repositories import ArtifactGraphRepository, GapRepository, StateRepository, TaskRepository
15
31
  from devcouncil.app.state_machine import ProjectPhase
32
+ from devcouncil.gating.policy import topological_order
16
33
  from devcouncil.live.summary import live_review_summary
17
34
  from devcouncil.reporting.report_builder import ReportBuilder
18
35
 
19
36
 
20
37
  console = Console()
38
+ logger = logging.getLogger(__name__)
21
39
 
22
40
  SUPPORTED_EXECUTORS = {
23
- "codex",
24
- "codex-cli",
25
- "gemini",
26
- "gemini-cli",
27
- "claude",
28
- "claude-code",
29
- "claude-cli",
41
+ *BUILTIN_CODING_EXECUTOR_NAMES,
30
42
  "native",
43
+ "native-preview",
31
44
  "mini",
32
45
  "openhands",
33
46
  }
47
+ SUPPORTED_EXECUTORS.update(AGENT_ALIASES)
34
48
 
35
49
  AGENT_REPORT_FILE = Path(".devcouncil/reports/latest.json")
36
50
 
@@ -39,12 +53,28 @@ def _normalize_executor(executor: str) -> str:
39
53
  return executor.strip().lower().replace("_", "-")
40
54
 
41
55
 
42
- def _configured_executor(root: Path) -> str:
56
+ def _is_git_repo(root: Path) -> bool:
57
+ """True when ``root`` is inside a git work tree.
58
+
59
+ The reconciliation pass re-verifies tasks against the *committed integrated*
60
+ state, which only exists when ``_commit_task_changes`` could actually commit —
61
+ i.e. inside a git repo. Without git there is nothing to reconcile (each task was
62
+ already verified in isolation during execution), and re-scanning a dirty,
63
+ non-git tree would spuriously flag unrelated files as orphan diffs.
64
+ """
43
65
  try:
44
- configured = load_config(root).execution.default_executor
45
- except FileNotFoundError:
46
- configured = "codex"
47
- return _normalize_executor(configured or "codex")
66
+ result = subprocess.run(
67
+ ["git", "rev-parse", "--is-inside-work-tree"],
68
+ cwd=root, capture_output=True, text=True,
69
+ )
70
+ except Exception:
71
+ return False
72
+ return result.returncode == 0 and result.stdout.strip() == "true"
73
+
74
+
75
+ def _custom_cli_agents(root: Path) -> set[str]:
76
+ specs = load_cli_agent_specs(root)
77
+ return {name for name, spec in specs.items() if not spec.built_in}
48
78
 
49
79
 
50
80
  def _load_tasks(root: Path):
@@ -55,6 +85,299 @@ def _load_tasks(root: Path):
55
85
  return TaskRepository(session).get_all()
56
86
 
57
87
 
88
+ def _task_status(root: Path, task_id: str) -> str:
89
+ latest = {item.id: item for item in _load_tasks(root)}.get(task_id)
90
+ return latest.status if latest else "missing"
91
+
92
+
93
+ def _max_repair_attempts(root: Path) -> int:
94
+ """How many self-repair attempts `dev go` may make per task (0 disables the loop)."""
95
+ try:
96
+ return max(0, int(load_config(root).execution.max_repair_attempts))
97
+ except Exception:
98
+ return 0
99
+
100
+
101
+ def _blocking_gap_signature(root: Path, task_id: str) -> str:
102
+ """Fingerprint of a task's current blocking gaps, for no-progress detection.
103
+
104
+ If a repair attempt reproduces the exact same blocking gaps as the previous one,
105
+ the agent is stuck — we abort rather than burn the rest of the budget repeating a
106
+ fix that does not move the gate.
107
+ """
108
+ db = get_db(root)
109
+ if not db:
110
+ return ""
111
+ with db.get_session() as session:
112
+ gaps = GapRepository(session).get_blocking_for_task(task_id)
113
+ key = "\n".join(sorted(f"{g.gap_type}:{g.description}" for g in gaps))
114
+ return hashlib.sha1(key.encode("utf-8")).hexdigest() if key else ""
115
+
116
+
117
+ def _remediable_incomplete_signature(root: Path, task_id: str) -> str:
118
+ """Fingerprint of a task's remediable "incomplete" gaps (unproven acceptance criteria
119
+ the executor could still prove), for driving and no-progress-checking the repair loop
120
+ when the task verified without a hard block but isn't actually done."""
121
+ from devcouncil.planning.correction_manifest import remediable_incomplete_gaps
122
+
123
+ db = get_db(root)
124
+ if not db:
125
+ return ""
126
+ with db.get_session() as session:
127
+ gaps = remediable_incomplete_gaps(GapRepository(session).get_for_task(task_id))
128
+ key = "\n".join(sorted(f"{g.gap_type}:{g.description}" for g in gaps))
129
+ return hashlib.sha1(key.encode("utf-8")).hexdigest() if key else ""
130
+
131
+
132
+ def _build_repair_service(root: Path):
133
+ """Best-effort LLM repair service used to sharpen the correction manifest's root
134
+ cause. Returns None when no provider key is configured — the manifest still has a
135
+ deterministic, task-scoped fallback (allowed files, commands, forbidden changes)."""
136
+ try:
137
+ from devcouncil.app.config import get_api_key
138
+ from devcouncil.llm.provider import create_provider, validate_model_provider
139
+ from devcouncil.llm.router import ModelRouter
140
+ from devcouncil.planning.repair_service import RepairService
141
+
142
+ config = load_config(root)
143
+ validate_model_provider(config.models.provider)
144
+ api_key = get_api_key(config.models.provider, root)
145
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
146
+ role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
147
+ return RepairService(ModelRouter(provider, role_config, project_root=root))
148
+ except Exception:
149
+ return None
150
+
151
+
152
+ def _execute_task_with_repair(
153
+ root: Path,
154
+ task,
155
+ *,
156
+ executor: str,
157
+ profile: str | None,
158
+ stream: bool,
159
+ max_repairs: int,
160
+ repair_service,
161
+ config=None,
162
+ ) -> tuple[str, int]:
163
+ """Run a task, then self-repair in a bounded loop until it verifies or the budget
164
+ is exhausted. Returns ``(final_status, repair_attempts_used)``.
165
+
166
+ Each repair attempt writes a correction manifest (which the coding-CLI executor
167
+ folds into its prompt) and re-runs the executor. Between attempts the prior work
168
+ is committed so the readiness gate's clean-tree requirement holds and the next
169
+ attempt builds on it; verification still recognises the committed work via the
170
+ task's checkpoint. The loop stops early when an attempt makes no progress (the
171
+ same blocking gaps reappear) so it never spins on an unfixable gate.
172
+
173
+ When the task ultimately verifies, the intermediate ``[blocked]`` commits made
174
+ between attempts are squashed into a single verified commit (see
175
+ :func:`_squash_repair_commits`) so failed attempts don't pollute git history. The
176
+ squash preserves the task's checkpoint refs, so the verifier's empty-diff guard
177
+ and ``dev rollback`` keep working.
178
+ """
179
+ from devcouncil.planning.correction_manifest import write_correction_manifest
180
+
181
+ def _run_once() -> None:
182
+ # An executor that raises (e.g. an experimental native agent hitting a
183
+ # StructuredOutputError, or a CLI crash) must not abort the whole `dev go`
184
+ # run — record it and let the loop/report treat the task as blocked.
185
+ try:
186
+ run_command.run(task.id, executor=executor, profile=profile, stream=stream, project_root=root)
187
+ except Exception as exc: # noqa: BLE001 - executor faults are non-fatal to the run
188
+ console.print(f"[red]{task.id}: executor '{executor}' errored: {exc}[/red]")
189
+
190
+ # HEAD before this task makes any commit, captured lazily right before the first
191
+ # intermediate commit. On a successful repair we `git reset --soft` back to here
192
+ # so only one verified commit remains (squashing the [blocked] attempts).
193
+ squash_base: str | None = None
194
+ intermediate_commits = 0
195
+
196
+ _run_once()
197
+ status = _task_status(root, task.id)
198
+ logger.info("Initial run of %s finished as %s (max_repairs=%d)", task.id, status, max_repairs)
199
+
200
+ attempt = 0
201
+ last_signature: str | None = None
202
+ while attempt < max_repairs:
203
+ blocked = status not in {"verified", "done"}
204
+ # When the task is blocked, repair against its blocking gaps. When it "verified"
205
+ # without a hard block but is still INCOMPLETE (an acceptance criterion the
206
+ # executor could prove has no passing evidence), keep repairing too — otherwise
207
+ # arm B stalls one proof short of done (the eval_rpn 5/7-incomplete case).
208
+ signature = (
209
+ _blocking_gap_signature(root, task.id) if blocked
210
+ else _remediable_incomplete_signature(root, task.id)
211
+ )
212
+ if not signature:
213
+ # Nothing concrete to repair: truly done, or blocked with no recorded gaps
214
+ # (e.g. the executor failed to start).
215
+ break
216
+ if signature == last_signature:
217
+ logger.warning("%s: repair made no progress (identical gaps) after attempt %d; stopping loop", task.id, attempt)
218
+ console.print(
219
+ f"[yellow]{task.id}: repair made no progress (identical blocking gaps); "
220
+ "stopping the self-repair loop.[/yellow]"
221
+ )
222
+ break
223
+ last_signature = signature
224
+
225
+ # Record where history started before the first failed-attempt commit, so the
226
+ # squash collapses exactly this task's intermediate commits and nothing earlier.
227
+ if squash_base is None:
228
+ squash_base = _current_head(root)
229
+
230
+ # Commit the prior attempt so the next run starts from a clean tree (the
231
+ # readiness gate requires it); the committed work stays visible to verify.
232
+ # Marked [blocked] and squashed away later when the task verifies.
233
+ if _commit_task_changes(root, task.id, status):
234
+ intermediate_commits += 1
235
+
236
+ manifest_path = write_correction_manifest(
237
+ root, task.id, repair_service=repair_service, config=config, include_incomplete=True
238
+ )
239
+ if manifest_path is None:
240
+ break
241
+ attempt += 1
242
+ logger.info("Self-repair attempt %d/%d for %s (was %s); manifest=%s", attempt, max_repairs, task.id, status, manifest_path)
243
+ console.print(
244
+ f"\n[bold]Self-repair attempt {attempt}/{max_repairs}[/bold] for "
245
+ f"[bold]{task.id}[/bold] (was {status})..."
246
+ )
247
+ _run_once()
248
+ status = _task_status(root, task.id)
249
+ logger.info("After repair attempt %d, %s is now %s", attempt, task.id, status)
250
+
251
+ if status not in {"verified", "done"} and attempt >= max_repairs and max_repairs > 0:
252
+ logger.warning("%s: gave up after %d repair attempt(s); still %s", task.id, attempt, status)
253
+ console.print(
254
+ f"[yellow]{task.id}: gave up after {attempt} repair attempt(s); still {status}.[/yellow]"
255
+ )
256
+
257
+ # On success, squash the [blocked] attempt commits into one verified commit so the
258
+ # user's history isn't littered with failed attempts. Only do this when the task
259
+ # actually verified — a still-blocked task keeps its attempt commits so the work
260
+ # isn't lost and the final reconciliation pass still sees committed changes.
261
+ if status in {"verified", "done"} and squash_base and intermediate_commits:
262
+ if _squash_repair_commits(root, task.id, squash_base, status):
263
+ logger.info("Squashed %d blocked attempt commit(s) for %s into one verified commit", intermediate_commits, task.id)
264
+ console.print(
265
+ f"[dim]Squashed {intermediate_commits} blocked attempt commit(s) for "
266
+ f"{task.id} into one verified commit.[/dim]"
267
+ )
268
+
269
+ return status, attempt
270
+
271
+
272
+ def _current_head(root: Path) -> str | None:
273
+ """Resolve the current HEAD commit, or None when there is no commit / no git."""
274
+ try:
275
+ result = subprocess.run(
276
+ ["git", "rev-parse", "HEAD"],
277
+ cwd=root, capture_output=True, text=True,
278
+ )
279
+ except Exception:
280
+ return None
281
+ if result.returncode != 0:
282
+ return None
283
+ head = result.stdout.strip()
284
+ return head or None
285
+
286
+
287
+ def _squash_repair_commits(root: Path, task_id: str, base: str, status: str) -> bool:
288
+ """Collapse this task's intermediate ``[blocked]`` commits into one verified commit.
289
+
290
+ Soft-resets HEAD back to ``base`` (the commit before the first failed-attempt
291
+ commit) and re-commits the combined tree once. A soft reset moves only the branch
292
+ pointer: the index and working tree are untouched and every prior commit object
293
+ stays alive while a ref points at it. The task's checkpoint refs
294
+ (``refs/devcouncil/tasks/<id>/before|after``) are independent named refs, so they
295
+ still resolve after the squash — which keeps the verifier's empty-diff guard
296
+ (``git diff <before_ref>``) and ``dev rollback`` (``git diff <before> <after>``)
297
+ working against the same commit objects as before.
298
+
299
+ Best-effort: any failure leaves the (already-committed) intermediate history in
300
+ place rather than risking the tree, and returns False. Returns True on success.
301
+ """
302
+ try:
303
+ # Guard: base must be a real ancestor we can reset to, and there must be
304
+ # commits since it to squash. If base == HEAD there is nothing to do.
305
+ head = _current_head(root)
306
+ if not head or head == base:
307
+ return False
308
+ base_ok = subprocess.run(
309
+ ["git", "rev-parse", "--verify", f"{base}^{{commit}}"],
310
+ cwd=root, capture_output=True, text=True,
311
+ )
312
+ if base_ok.returncode != 0:
313
+ return False
314
+ # Soft reset keeps the working tree + index exactly as-is; only the branch
315
+ # pointer moves back to base, so the next commit captures the whole task.
316
+ reset = subprocess.run(
317
+ ["git", "reset", "--soft", base],
318
+ cwd=root, capture_output=True, text=True,
319
+ )
320
+ if reset.returncode != 0:
321
+ return False
322
+ # Stage the FINAL (verified) attempt's still-uncommitted changes too, so they land
323
+ # in this single squash commit. Without this they'd be committed separately by the
324
+ # caller afterward, producing two commits for what the message calls "one verified
325
+ # commit" (and leaving the squash to capture only the [blocked] diffs).
326
+ add = subprocess.run(
327
+ ["git", "add", "-A"],
328
+ cwd=root, capture_output=True, text=True,
329
+ )
330
+ if add.returncode != 0:
331
+ return False
332
+ # Re-commit the squashed tree. There may be nothing staged if every attempt's
333
+ # changes cancelled out (unlikely for a verified task) — tolerate that.
334
+ commit = subprocess.run(
335
+ [
336
+ "git",
337
+ "-c", "user.name=DevCouncil",
338
+ "-c", "user.email=devcouncil@local",
339
+ "commit", "--no-verify", "--allow-empty",
340
+ "-m", f"devcouncil(e2e): {task_id} [{status}]",
341
+ ],
342
+ cwd=root, capture_output=True, text=True,
343
+ )
344
+ return commit.returncode == 0
345
+ except Exception:
346
+ return False
347
+
348
+
349
+ def _commit_task_changes(root: Path, task_id: str, status: str) -> bool:
350
+ """Commit the working-tree changes a task produced.
351
+
352
+ Sequential plans build on each other and the task-readiness gate requires a
353
+ clean working tree, so without this each task after the first is blocked by
354
+ the previous task's uncommitted changes. Commits are clearly attributed to
355
+ DevCouncil (via ``-c`` so the user's git identity/config is never mutated)
356
+ and can be squashed or reset afterwards. Returns True if a commit was made.
357
+ """
358
+ try:
359
+ status_out = subprocess.run(
360
+ ["git", "status", "--porcelain"],
361
+ cwd=root, capture_output=True, text=True,
362
+ )
363
+ if status_out.returncode != 0 or not status_out.stdout.strip():
364
+ return False
365
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True, capture_output=True)
366
+ commit = subprocess.run(
367
+ [
368
+ "git",
369
+ "-c", "user.name=DevCouncil",
370
+ "-c", "user.email=devcouncil@local",
371
+ "commit", "--no-verify",
372
+ "-m", f"devcouncil(e2e): {task_id} [{status}]",
373
+ ],
374
+ cwd=root, capture_output=True, text=True,
375
+ )
376
+ return commit.returncode == 0
377
+ except Exception:
378
+ return False
379
+
380
+
58
381
  def _load_tasks_by_id(root: Path, task_ids: list[str]):
59
382
  db = get_db(root)
60
383
  if not db:
@@ -127,7 +450,7 @@ def _command_label(ctx: typer.Context) -> str:
127
450
 
128
451
  def go(
129
452
  ctx: typer.Context,
130
- goal: str = typer.Argument(..., help="Implementation goal to plan, execute, verify, and report."),
453
+ goal: str = typer.Argument(..., help="Implementation goal to plan, execute, verify, and report. Also accepts a GitHub issue/PR reference (#142, owner/repo#142, or a github.com URL), whose title+body becomes the goal."),
131
454
  executor: str | None = typer.Option(
132
455
  None,
133
456
  "--executor",
@@ -135,6 +458,20 @@ def go(
135
458
  help="Automated executor to use. Defaults to execution.default_executor in .devcouncil/config.yaml.",
136
459
  ),
137
460
  dry_run: bool = typer.Option(False, "--dry-run", help="Use mock planning responses for local smoke testing."),
461
+ quick: bool = typer.Option(
462
+ False,
463
+ "--quick",
464
+ help="Rigor dial: skip the planning council (A/B debate, critique, rebuttal, arbitration) "
465
+ "for a single spec + plan. Faster and cheaper; verification still gates every diff.",
466
+ ),
467
+ force: bool = typer.Option(
468
+ False,
469
+ "--force",
470
+ "--yes",
471
+ "-y",
472
+ help="Proceed past unresolved planning gaps (critique findings, blocking questions) "
473
+ "without manual approval. Verification still gates each task's actual diff.",
474
+ ),
138
475
  continue_on_blocked: bool = typer.Option(
139
476
  False,
140
477
  "--continue-on-blocked",
@@ -151,75 +488,237 @@ def go(
151
488
  "--agent",
152
489
  help="Use coding-agent defaults: JSON report plus .devcouncil/reports/latest.json.",
153
490
  ),
491
+ profile: str | None = typer.Option(None, "--profile", help="CLI-agent execution profile to pass to dev run."),
492
+ stream: bool = typer.Option(
493
+ False,
494
+ "--stream",
495
+ help="Stream coding CLI stdout/stderr live during execution (also enabled by execution.stream_cli_output).",
496
+ ),
154
497
  project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
155
498
  ):
156
499
  """
157
500
  Run the full DevCouncil loop in one command.
158
501
  """
159
502
  root = project_root.expanduser().resolve()
503
+ set_log_dir(root)
504
+ logger.info(
505
+ "dev go starting: goal=%r executor=%s quick=%s force=%s root=%s",
506
+ goal, executor, quick, force, root,
507
+ )
160
508
  initialize_project(root, quiet=True)
509
+
510
+ # A goal like "#142" or a GitHub issue/PR URL is a reference, not a spec —
511
+ # expand it into the issue/PR title + body (the real intent) via the gh CLI.
512
+ expanded_goal, intent_note = resolve_goal_intent(goal, root)
513
+ if intent_note:
514
+ console.print(f"[dim]{intent_note}[/dim]")
515
+ goal = expanded_goal
516
+
161
517
  if agent:
162
518
  json_report = True
163
519
  if report_file is None:
164
520
  report_file = AGENT_REPORT_FILE
165
521
 
166
- normalized_executor = _normalize_executor(executor) if executor else _configured_executor(root)
522
+ normalized_executor = resolve_automated_executor(root, executor)
167
523
  command_label = _command_label(ctx)
168
524
  if normalized_executor == "manual":
169
525
  console.print(
170
526
  f"[red]`{command_label}` requires an automated executor. "
171
- "Use `dev run TASK-ID --executor manual` for handoff mode.[/red]"
527
+ "Set execution.default_executor in .devcouncil/config.yaml or install a coding CLI on PATH.[/red]"
172
528
  )
173
529
  raise typer.Exit(code=2)
174
- if normalized_executor not in SUPPORTED_EXECUTORS:
530
+ if executor is None and normalized_executor != "manual":
531
+ console.print(
532
+ f"[dim]Using automated executor:[/dim] [bold]{normalized_executor}[/bold] "
533
+ "(from config or first coding CLI found on PATH)."
534
+ )
535
+ supported = SUPPORTED_EXECUTORS | _custom_cli_agents(root)
536
+ if normalized_executor not in supported:
175
537
  console.print(
176
538
  f"[red]Unsupported executor for `{command_label}`: "
177
- f"{normalized_executor}. Supported: {', '.join(sorted(SUPPORTED_EXECUTORS))}.[/red]"
539
+ f"{normalized_executor}. Supported: {', '.join(sorted(supported))}.[/red]"
178
540
  )
179
541
  raise typer.Exit(code=2)
180
542
 
181
543
  console.print(f"[bold]Planning goal:[/bold] {goal}")
182
- planned_task_ids = asyncio.run(plan_command.run_plan_flow(goal, dry_run=dry_run, persist=True, project_root=root))
544
+ try:
545
+ with log_stage("plan", project_root=root, quick=quick, dry_run=dry_run):
546
+ planned_task_ids = asyncio.run(plan_command.run_plan_flow(goal, dry_run=dry_run, persist=True, project_root=root, quick=quick))
547
+ except (ProviderRequestError, StructuredOutputError) as exc:
548
+ plan_command.print_planning_error(exc)
549
+ raise typer.Exit(code=1)
183
550
 
184
551
  task_ids = _unique_task_ids(planned_task_ids or [])
185
- tasks, missing_task_ids = _load_tasks_by_id(root, task_ids)
186
- if missing_task_ids:
187
- console.print(f"[red]Planning returned task IDs that were not persisted: {', '.join(missing_task_ids)}[/red]")
188
- raise typer.Exit(code=1)
189
- if not tasks:
190
- console.print("[red]Planning did not produce any approved tasks.[/red]")
191
- raise typer.Exit(code=1)
552
+ # The planning council almost always raises advisory gaps (critique findings,
553
+ # clarifying questions), so run_plan_flow returns no approved tasks and the
554
+ # plan is left in AWAITING_USER_DECISIONS. For an automated one-command flow
555
+ # that means there is nothing to run. With --force, approve the generated plan
556
+ # anyway and proceed — verification still gates each task's actual diff.
557
+ if not task_ids:
558
+ if force:
559
+ logger.info("No auto-approved tasks; force-approving generated plan past planning gaps")
560
+ try:
561
+ plan_command.approve(run_id=None, force=True, project_root=root)
562
+ except SystemExit:
563
+ pass
564
+ tasks = _load_tasks(root)
565
+ if tasks:
566
+ console.print(
567
+ "[yellow]Proceeding past planning gaps via --force; "
568
+ "verification still gates each task.[/yellow]"
569
+ )
570
+ else:
571
+ tasks = []
572
+ if not tasks:
573
+ logger.warning("Planning produced no approved tasks; aborting run")
574
+ console.print("[red]Planning did not produce any approved tasks.[/red]")
575
+ console.print(
576
+ "Review gaps with [bold]dev status[/bold], then run [bold]dev approve[/bold] "
577
+ "to accept the plan — or re-run with [bold]--force[/bold] to proceed past "
578
+ "advisory planning gaps automatically."
579
+ )
580
+ raise typer.Exit(code=1)
581
+ else:
582
+ tasks, missing_task_ids = _load_tasks_by_id(root, task_ids)
583
+ if missing_task_ids:
584
+ console.print(f"[red]Planning returned task IDs that were not persisted: {', '.join(missing_task_ids)}[/red]")
585
+ raise typer.Exit(code=1)
586
+ if not tasks:
587
+ console.print("[red]Planning did not produce any approved tasks.[/red]")
588
+ raise typer.Exit(code=1)
192
589
 
193
590
  failed: list[str] = []
194
591
  executed_task_ids: list[str] = []
592
+ # Automated executors can self-repair; manual sidecar mode cannot (a human drives
593
+ # the edits), so the repair loop only applies to automated runs.
594
+ max_repairs = _max_repair_attempts(root) if normalized_executor != "manual" else 0
595
+ repair_service = _build_repair_service(root) if max_repairs else None
596
+ # Load config once for the repair loop so the correction-manifest builder doesn't
597
+ # reload it from disk on every repair attempt. Only needed when the loop is active;
598
+ # the builder falls back to loading config itself if this is None.
599
+ repair_config = load_config(root) if max_repairs else None
600
+ # Run tasks in dependency order so a task never executes before the tasks it needs.
601
+ tasks = topological_order(tasks)
602
+ log_step(
603
+ f"execution plan: {len(tasks)} task(s) in dependency order",
604
+ project_root=root,
605
+ order=[t.id for t in tasks],
606
+ )
607
+ completed_ids = {task.id for task in tasks if task.status in {"verified", "done"}}
195
608
  for task in tasks:
196
609
  if task.status in {"verified", "done"}:
610
+ logger.info("Skipping %s; already %s", task.id, task.status)
197
611
  console.print(f"[green]Skipping {task.id}; already {task.status}.[/green]")
612
+ completed_ids.add(task.id)
613
+ continue
614
+
615
+ # Don't run a task whose prerequisites didn't complete — it would fail for an
616
+ # unrelated reason and (with the repair loop) burn its whole budget against an
617
+ # unsatisfiable precondition. Skip it and surface why.
618
+ unmet = [dep for dep in task.depends_on if dep not in completed_ids]
619
+ if unmet:
620
+ logger.warning("Skipping %s: upstream %s not completed", task.id, ", ".join(unmet))
621
+ console.print(f"[yellow]Skipping {task.id}: upstream {', '.join(unmet)} not completed.[/yellow]")
622
+ failed.append(f"{task.id} (skipped: upstream {', '.join(unmet)} unsatisfied)")
198
623
  continue
199
624
 
200
625
  console.print(f"\n[bold]Executing {task.id}[/bold] with [bold]{normalized_executor}[/bold]...")
201
626
  executed_task_ids.append(task.id)
202
- run_command.run(task.id, executor=normalized_executor, project_root=root)
627
+ # Run, then self-repair in a bounded loop (closes the autonomous loop: the
628
+ # one-shot executor no longer needs a human to run `dev repair` and re-run).
629
+ with log_stage(
630
+ "execute_task",
631
+ project_root=root,
632
+ task_id=task.id,
633
+ executor=normalized_executor,
634
+ max_repairs=max_repairs,
635
+ ):
636
+ latest_status, repairs_used = _execute_task_with_repair(
637
+ root,
638
+ task,
639
+ executor=normalized_executor,
640
+ profile=profile,
641
+ stream=stream,
642
+ max_repairs=max_repairs,
643
+ repair_service=repair_service,
644
+ config=repair_config,
645
+ )
646
+ log_step(
647
+ f"task {task.id} finished as {latest_status}",
648
+ project_root=root,
649
+ task_id=task.id,
650
+ repairs_used=repairs_used,
651
+ trace=True,
652
+ )
203
653
 
204
- latest = {item.id: item for item in _load_tasks(root)}.get(task.id)
205
- latest_status = latest.status if latest else "missing"
206
- if latest_status not in {"verified", "done"}:
654
+ # Commit whatever this task produced so the next task in the plan starts
655
+ # from a clean tree otherwise its readiness gate blocks on the dirty
656
+ # tree and the whole multi-task plan stalls after task one.
657
+ if _commit_task_changes(root, task.id, latest_status):
658
+ note = f" after {repairs_used} repair attempt(s)" if repairs_used else ""
659
+ logger.info("Committed %s changes (%s)%s", task.id, latest_status, note)
660
+ console.print(f"[dim]Committed {task.id} changes ({latest_status}){note}.[/dim]")
661
+
662
+ if latest_status in {"verified", "done"}:
663
+ completed_ids.add(task.id)
664
+ else:
207
665
  failed.append(f"{task.id} ({latest_status})")
208
- if latest_status != "blocked" or not continue_on_blocked:
666
+ # --continue-on-blocked is a "run the whole plan, best effort" switch:
667
+ # don't let one task that blocked or could not start halt the rest. The
668
+ # final reconciliation pass judges the integrated result fairly.
669
+ if not continue_on_blocked:
670
+ logger.warning("Stopping run: %s ended as %s (no --continue-on-blocked)", task.id, latest_status)
209
671
  console.print(f"[red]Stopping because {task.id} ended as {latest_status}.[/red]")
210
672
  break
673
+ logger.info("%s ended as %s; continuing to next task (--continue-on-blocked)", task.id, latest_status)
674
+ console.print(f"[yellow]{task.id} ended as {latest_status}; continuing to the next task.[/yellow]")
211
675
 
212
676
  if not executed_task_ids:
213
677
  failed.append("all planned tasks were already completed before execution")
214
678
 
679
+ # Final reconciliation: re-verify every task against the fully integrated,
680
+ # committed state. Earlier tasks are verified before later tasks create shared
681
+ # test files, so their gates can pass now even though they blocked mid-run.
682
+ # The tree is clean here, so verification uses each task's committed checkpoint
683
+ # diff to prove its acceptance criteria (rather than skipping on an empty diff and
684
+ # wrongly blocking). Re-running the same diff is largely an LLM-cache hit, so this
685
+ # refreshes statuses/gaps cheaply for an honest final report.
686
+ if executed_task_ids and _is_git_repo(root):
687
+ console.print("\n[bold]Reconciling verification against the final integrated state...[/bold]")
688
+ log_step("reconcile: re-verifying against integrated state", project_root=root)
689
+ try:
690
+ verify_command.verify(task_id=None, sandbox="local", json_format=True, project_root=root)
691
+ except typer.Exit:
692
+ # Expected signal: verify() raises Exit(code=1) when any task is blocked,
693
+ # but it has already persisted every task status before raising. The
694
+ # reconciliation pass therefore completed — fall through to the reload so
695
+ # blocked statuses are refreshed honestly. (Only real errors should skip.)
696
+ pass
697
+ except Exception as exc: # pragma: no cover - reconciliation is best-effort
698
+ console.print(f"[yellow]Reconciliation pass skipped: {exc}[/yellow]")
699
+ reconciled = {item.id: item for item in _load_tasks(root)}
700
+ # Rebuild from the FULL planned set, not just executed_task_ids: a task skipped
701
+ # for an unmet dependency, or one reconciliation downgraded from done->blocked,
702
+ # must still count as unfinished — otherwise `dev go` reports success while work
703
+ # is incomplete.
704
+ failed = []
705
+ for planned in tasks:
706
+ item = reconciled.get(planned.id)
707
+ if not (item and item.status in {"verified", "done"}):
708
+ status = item.status if item else "missing"
709
+ failed.append(f"{planned.id} ({status})")
710
+
215
711
  if not failed:
712
+ logger.info("dev go complete: all tasks finished")
216
713
  _record_project_done(root)
217
714
  else:
715
+ logger.warning("dev go finished with %d unfinished task(s): %s", len(failed), ", ".join(failed))
218
716
  _record_project_blocked(root)
219
717
 
718
+ log_step("generating final report", project_root=root)
220
719
  console.print("\n[bold]Final DevCouncil report[/bold]")
221
720
  report_command.report(
222
- SimpleNamespace(invoked_subcommand=None),
721
+ SimpleNamespace(invoked_subcommand=None), # type: ignore[arg-type]
223
722
  planning_only=False,
224
723
  json_format=json_report,
225
724
  github=False,