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