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.
- package/README.md +190 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +167 -5
- package/src/devcouncil/artifacts/graph.py +23 -3
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/doctor.py +221 -21
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +452 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +124 -15
- package/src/devcouncil/cli/commands/init.py +154 -18
- package/src/devcouncil/cli/commands/integrate.py +894 -105
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/plan.py +212 -51
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +40 -23
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +69 -49
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +25 -1
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +138 -3
- package/src/devcouncil/cli/commands/watch.py +9 -9
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +56 -7
- package/src/devcouncil/domain/evidence.py +22 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +59 -10
- package/src/devcouncil/execution/permissions.py +17 -24
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +633 -21
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +6 -2
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +663 -39
- package/src/devcouncil/executors/native/agent.py +121 -20
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
- package/src/devcouncil/gating/policy.py +158 -10
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +1 -1
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1256 -9
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +35 -0
- package/src/devcouncil/integrations/mcp/server.py +1552 -29
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/live/cards.py +161 -19
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/transcripts.py +9 -6
- package/src/devcouncil/llm/cache.py +10 -6
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +515 -34
- package/src/devcouncil/llm/router.py +231 -46
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +7 -2
- package/src/devcouncil/planning/plan_service.py +17 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
- package/src/devcouncil/planning/spec_service.py +27 -1
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/json_report.py +11 -1
- package/src/devcouncil/reporting/markdown_report.py +15 -0
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +83 -2
- package/src/devcouncil/storage/models.py +121 -0
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +137 -75
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +12 -9
- package/src/devcouncil/ui/dashboard.py +324 -23
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1065 -47
- package/uv.lock +205 -64
- 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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
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
|
-
"
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|