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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- 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 +220 -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/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- 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 +195 -54
- 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 +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- 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 +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- 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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
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
|
-
"
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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,
|