devcouncil 0.2.0 → 0.3.1
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 +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- 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 +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- 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 +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -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/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- package/uv.lock +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import os
|
|
6
7
|
import shlex
|
|
7
8
|
import shutil
|
|
@@ -20,6 +21,8 @@ from devcouncil.storage.native import ShellCommandRepository, ShellSessionReposi
|
|
|
20
21
|
from devcouncil.storage.repositories import EvidenceRepository, TaskRepository
|
|
21
22
|
from devcouncil.telemetry.traces import TraceLogger
|
|
22
23
|
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
class ShellBackend:
|
|
25
28
|
def run_command(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
|
@@ -137,6 +140,7 @@ class GuardedShellSession:
|
|
|
137
140
|
stderr_path = self.log_dir / f"{self.task.id}-{log_id}.stderr.log"
|
|
138
141
|
|
|
139
142
|
if decision.action == "deny":
|
|
143
|
+
logger.warning("Shell command DENIED for %s: %s (%s)", self.task.id, normalized, decision.reason)
|
|
140
144
|
self._record_command(normalized, "denied", reason=decision.reason)
|
|
141
145
|
TraceLogger(self.project_root).log_event(
|
|
142
146
|
"shell_command_denied",
|
|
@@ -152,9 +156,11 @@ class GuardedShellSession:
|
|
|
152
156
|
)
|
|
153
157
|
return 1
|
|
154
158
|
|
|
159
|
+
logger.info("Shell command for %s: %s", self.task.id, normalized)
|
|
155
160
|
try:
|
|
156
161
|
result = self.backend.run_command(normalized, self.project_root)
|
|
157
162
|
except (NotImplementedError, FileNotFoundError, OSError) as exc:
|
|
163
|
+
logger.warning("Shell command could not run for %s: %s (%s)", self.task.id, normalized, exc)
|
|
158
164
|
self._record_command(normalized, "denied", reason=str(exc))
|
|
159
165
|
console.print(f"[red]Could not run '{normalized}':[/red] {exc}")
|
|
160
166
|
return 1
|
|
@@ -5,8 +5,11 @@ import logging
|
|
|
5
5
|
import re
|
|
6
6
|
import shlex
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Literal, Optional
|
|
8
|
+
from typing import Literal, Optional, TYPE_CHECKING
|
|
9
9
|
from devcouncil.domain.task import Task
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from devcouncil.app.config import DevCouncilConfig
|
|
10
13
|
from devcouncil.execution.permissions import PermissionManager
|
|
11
14
|
from devcouncil.domain.evidence import CommandResult
|
|
12
15
|
from devcouncil.app.errors import ExecutionError
|
|
@@ -25,6 +28,16 @@ class TaskRunner:
|
|
|
25
28
|
self.project_root = project_root
|
|
26
29
|
self.permissions = permission_manager
|
|
27
30
|
self.patch_engine = PatchEngine(project_root)
|
|
31
|
+
# Load config and build the trace logger once: run_command previously
|
|
32
|
+
# re-parsed config.yaml on every command, and each operation rebuilt a
|
|
33
|
+
# TraceLogger. Both are reusable for the lifetime of the runner.
|
|
34
|
+
self.config: "Optional[DevCouncilConfig]"
|
|
35
|
+
try:
|
|
36
|
+
from devcouncil.app.config import load_config
|
|
37
|
+
self.config = load_config(project_root)
|
|
38
|
+
except Exception:
|
|
39
|
+
self.config = None
|
|
40
|
+
self.tracer = TraceLogger(project_root)
|
|
28
41
|
|
|
29
42
|
def _validate_path_within_root(self, path: str) -> None:
|
|
30
43
|
"""Ensure a path resolves to a location within the project root."""
|
|
@@ -37,7 +50,7 @@ class TaskRunner:
|
|
|
37
50
|
self._validate_path_within_root(path)
|
|
38
51
|
self.permissions.validate_action("file_write", path, task, operation=operation)
|
|
39
52
|
applied = self.patch_engine.apply_patch(patch)
|
|
40
|
-
|
|
53
|
+
self.tracer.log_event(
|
|
41
54
|
"tool_patch_applied",
|
|
42
55
|
{"paths": sorted(changes), "success": applied},
|
|
43
56
|
task_id=task.id,
|
|
@@ -107,9 +120,7 @@ class TaskRunner:
|
|
|
107
120
|
logger.info(f"Executing authorized command: {command}")
|
|
108
121
|
|
|
109
122
|
try:
|
|
110
|
-
|
|
111
|
-
config = load_config(self.project_root)
|
|
112
|
-
timeout = config.execution.command_timeout
|
|
123
|
+
timeout = self.config.execution.command_timeout if self.config is not None else 300
|
|
113
124
|
except Exception:
|
|
114
125
|
timeout = 300
|
|
115
126
|
|
|
@@ -133,7 +144,7 @@ class TaskRunner:
|
|
|
133
144
|
stderr_path = self._save_command_log(task.id, command, "stderr", stderr)
|
|
134
145
|
stdout_summary = redact_string(stdout[-500:])
|
|
135
146
|
stderr_summary = redact_string(stderr[-500:])
|
|
136
|
-
|
|
147
|
+
self.tracer.log_event(
|
|
137
148
|
"command_executed",
|
|
138
149
|
{"command": command, "exit_code": result.returncode},
|
|
139
150
|
task_id=task.id,
|
|
@@ -160,7 +171,7 @@ class TaskRunner:
|
|
|
160
171
|
try:
|
|
161
172
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
173
|
full_path.write_text(content, encoding="utf-8")
|
|
163
|
-
|
|
174
|
+
self.tracer.log_event(
|
|
164
175
|
"file_written",
|
|
165
176
|
{"path": path},
|
|
166
177
|
task_id=task.id,
|
|
@@ -95,6 +95,11 @@ def normalize_agent_name(name: str) -> str:
|
|
|
95
95
|
|
|
96
96
|
|
|
97
97
|
def resolve_cursor_agent_executable() -> str | None:
|
|
98
|
+
# NOTE: intentionally NOT lru_cached. It takes no project_root key, so a
|
|
99
|
+
# process-wide cache would leak one test's PATH probe (which the suite
|
|
100
|
+
# monkeypatches shutil.which to control) into the next, breaking isolation.
|
|
101
|
+
# The PATH search is two cheap shutil.which() calls; the dominant repeated
|
|
102
|
+
# cost (load_cli_agent_specs / config parsing) is cached instead.
|
|
98
103
|
for candidate in ("cursor-agent", "agent"):
|
|
99
104
|
if shutil.which(candidate):
|
|
100
105
|
return candidate
|
|
@@ -246,8 +251,19 @@ def detect_available_coding_cli(
|
|
|
246
251
|
project_root: Path,
|
|
247
252
|
probe_order: tuple[str, ...] | None = None,
|
|
248
253
|
) -> str | None:
|
|
254
|
+
# Load specs once for the whole probe rather than per client (each
|
|
255
|
+
# resolve_coding_cli_executable() call would otherwise rebuild the spec
|
|
256
|
+
# table and re-parse config.yaml). Resolution below mirrors
|
|
257
|
+
# resolve_coding_cli_executable() exactly, just against the prebuilt table.
|
|
258
|
+
specs = load_cli_agent_specs(project_root)
|
|
249
259
|
for client in probe_order or resolve_coding_cli_probe_order(project_root):
|
|
250
|
-
|
|
260
|
+
normalized = normalize_agent_name(client)
|
|
261
|
+
if normalized == "cursor":
|
|
262
|
+
if resolve_cursor_agent_executable():
|
|
263
|
+
return client
|
|
264
|
+
continue
|
|
265
|
+
spec = specs.get(normalized)
|
|
266
|
+
if spec and shutil.which(spec.executable):
|
|
251
267
|
return client
|
|
252
268
|
return None
|
|
253
269
|
|
|
@@ -453,6 +469,11 @@ def builtin_agent_specs(project_root: Path) -> dict[str, CliAgentSpec]:
|
|
|
453
469
|
|
|
454
470
|
|
|
455
471
|
def load_cli_agent_specs(project_root: Path) -> dict[str, CliAgentSpec]:
|
|
472
|
+
# NOTE: intentionally NOT cached. Callers (e.g. `setup` then `integrate
|
|
473
|
+
# doctor`) rewrite config.yaml for the same project_root within a single
|
|
474
|
+
# process and expect the next load to reflect it, so this must stay a live
|
|
475
|
+
# read. The repeated-reload hot path (the CLI probe loop) instead hoists a
|
|
476
|
+
# single load — see detect_available_coding_cli.
|
|
456
477
|
specs = builtin_agent_specs(project_root)
|
|
457
478
|
try:
|
|
458
479
|
configured = load_config(project_root).integrations.cli_agents.agents
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import logging
|
|
2
3
|
import os
|
|
3
4
|
import queue
|
|
4
5
|
import shutil
|
|
@@ -9,12 +10,13 @@ import time
|
|
|
9
10
|
import uuid
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
12
14
|
|
|
13
15
|
from rich.console import Console
|
|
14
16
|
|
|
15
17
|
from devcouncil.domain.requirement import Requirement
|
|
16
18
|
from devcouncil.domain.task import Task
|
|
17
|
-
from devcouncil.app.config import load_config
|
|
19
|
+
from devcouncil.app.config import DevCouncilConfig, load_config
|
|
18
20
|
from devcouncil.execution.executor import Executor, ExecutionResult
|
|
19
21
|
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
20
22
|
from devcouncil.executors.agent_registry import (
|
|
@@ -27,9 +29,17 @@ from devcouncil.executors.agent_registry import (
|
|
|
27
29
|
)
|
|
28
30
|
from devcouncil.repo.gitignore import ensure_gitignore
|
|
29
31
|
from devcouncil.telemetry.traces import TraceLogger
|
|
32
|
+
from devcouncil.telemetry.logging_setup import run_log
|
|
30
33
|
from devcouncil.utils.redaction import redact_text
|
|
31
34
|
|
|
32
35
|
console = Console()
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# DevCouncil-managed scaffolding `dev` writes into a workspace itself (agent guides, the
|
|
39
|
+
# managed .gitignore). The pre-verify scope gate must never revert these — they are not
|
|
40
|
+
# task work, and reverting them would undo `dev`'s own setup. .devcouncil/* is handled
|
|
41
|
+
# by a prefix check at the call site.
|
|
42
|
+
_SCAFFOLDING_PATHS = frozenset({"AGENTS.md", "AGENTS.json", "CLAUDE.md", ".gitignore"})
|
|
33
43
|
|
|
34
44
|
|
|
35
45
|
class CodingCliExecutor(Executor):
|
|
@@ -44,6 +54,14 @@ class CodingCliExecutor(Executor):
|
|
|
44
54
|
stream_output: bool | None = None,
|
|
45
55
|
):
|
|
46
56
|
self.project_root = project_root
|
|
57
|
+
# Load the project config once per executor instance. The same handle is
|
|
58
|
+
# reused by _resolve_stream_output, _cursor_resume_mode and the Warp
|
|
59
|
+
# command builder, which would otherwise each re-parse config.yaml.
|
|
60
|
+
self._config: Optional[DevCouncilConfig]
|
|
61
|
+
try:
|
|
62
|
+
self._config = load_config(project_root)
|
|
63
|
+
except Exception:
|
|
64
|
+
self._config = None
|
|
47
65
|
self.client = self._normalize_client(client)
|
|
48
66
|
self.timeout_seconds = timeout_seconds
|
|
49
67
|
self.spec = self._resolve_spec()
|
|
@@ -66,7 +84,9 @@ class CodingCliExecutor(Executor):
|
|
|
66
84
|
if stream_output is not None:
|
|
67
85
|
return stream_output
|
|
68
86
|
try:
|
|
69
|
-
|
|
87
|
+
if self._config is None:
|
|
88
|
+
return False
|
|
89
|
+
return bool(self._config.execution.stream_cli_output)
|
|
70
90
|
except Exception:
|
|
71
91
|
return False
|
|
72
92
|
|
|
@@ -193,8 +213,10 @@ class CodingCliExecutor(Executor):
|
|
|
193
213
|
|
|
194
214
|
def _load_warp_config(self) -> dict:
|
|
195
215
|
try:
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
if self._config is None:
|
|
217
|
+
data = {}
|
|
218
|
+
else:
|
|
219
|
+
data = self._config.integrations.warp.model_dump()
|
|
198
220
|
except Exception:
|
|
199
221
|
data = {}
|
|
200
222
|
if command := os.environ.get("DEVCOUNCIL_WARP_COMMAND"):
|
|
@@ -210,12 +232,15 @@ class CodingCliExecutor(Executor):
|
|
|
210
232
|
return data
|
|
211
233
|
|
|
212
234
|
def run_task(self, task: Task, requirements: list[Requirement]) -> ExecutionResult:
|
|
235
|
+
logger.info("coding_cli.run_task: client=%s profile=%s task=%s", self.client, self.profile_name, task.id)
|
|
213
236
|
if self.profile is None:
|
|
237
|
+
logger.error("Unknown agent profile %r for %s; cannot start.", self.profile_name, self.client)
|
|
214
238
|
return ExecutionResult(
|
|
215
239
|
success=False,
|
|
216
240
|
message=f"Unknown agent profile '{self.profile_name}' for {self.client}.",
|
|
217
241
|
)
|
|
218
242
|
if self.spec.input_mode not in VALID_INPUT_MODES:
|
|
243
|
+
logger.error("Invalid input_mode %r for %s; cannot start.", self.spec.input_mode, self.client)
|
|
219
244
|
return ExecutionResult(
|
|
220
245
|
success=False,
|
|
221
246
|
message=(
|
|
@@ -229,10 +254,12 @@ class CodingCliExecutor(Executor):
|
|
|
229
254
|
try:
|
|
230
255
|
command = self._command(task.id)
|
|
231
256
|
except ValueError as exc:
|
|
257
|
+
logger.error("Failed to build %s command for %s: %s", self.client, task.id, exc)
|
|
232
258
|
return ExecutionResult(success=False, message=str(exc))
|
|
233
259
|
|
|
234
260
|
executable = command[0]
|
|
235
261
|
if not shutil.which(executable):
|
|
262
|
+
logger.error("%s CLI executable %r not found on PATH.", self.client, executable)
|
|
236
263
|
return ExecutionResult(
|
|
237
264
|
success=False,
|
|
238
265
|
message=f"{self.client} CLI is not installed or not on PATH.",
|
|
@@ -263,6 +290,12 @@ class CodingCliExecutor(Executor):
|
|
|
263
290
|
console.print(f"Starting [bold]{self.client.upper()}[/bold] for task [bold]{task.id}[/bold]...")
|
|
264
291
|
console.print(f"Task prompt: [dim]{instruction_file}[/dim]")
|
|
265
292
|
|
|
293
|
+
# Isolate this run's full DEBUG trail in its own run-dir log, on top of the
|
|
294
|
+
# always-on shared devcouncil.log, so an agent run can be inspected end-to-end
|
|
295
|
+
# without grepping across unrelated activity / log rotations.
|
|
296
|
+
run_log_cm = run_log(self.project_root / ".devcouncil" / "runs" / run_id / "run.log")
|
|
297
|
+
run_log_cm.__enter__()
|
|
298
|
+
|
|
266
299
|
started = time.monotonic()
|
|
267
300
|
try:
|
|
268
301
|
invocation, input_text = self._invocation(command, prompt, instruction_file)
|
|
@@ -296,8 +329,10 @@ class CodingCliExecutor(Executor):
|
|
|
296
329
|
else None
|
|
297
330
|
)
|
|
298
331
|
started = time.monotonic()
|
|
332
|
+
logger.info("Launching %s subprocess for %s (timeout=%ss)", self.client, task.id, self._effective_timeout())
|
|
299
333
|
result = self._run_subprocess(invocation, input_text, env, transcript_path=transcript_path)
|
|
300
334
|
duration = round(time.monotonic() - started, 3)
|
|
335
|
+
logger.info("%s subprocess for %s exited %s in %.2fs", self.client, task.id, result.returncode, duration)
|
|
301
336
|
finished_at = datetime.now(timezone.utc).isoformat()
|
|
302
337
|
self._write_log(log_prefix, result)
|
|
303
338
|
if transcript_path and transcript_path.exists():
|
|
@@ -316,6 +351,7 @@ class CodingCliExecutor(Executor):
|
|
|
316
351
|
)
|
|
317
352
|
stderr_preview = (result.stderr or result.stdout or "").strip().splitlines()[:5]
|
|
318
353
|
detail = redact_text(stderr_preview[0]) if stderr_preview else "No diagnostics were produced."
|
|
354
|
+
logger.error("%s exited %s for %s: %s", self.client, result.returncode, task.id, detail)
|
|
319
355
|
TraceLogger(self.project_root).log_event(
|
|
320
356
|
"agent_run_failed",
|
|
321
357
|
{"agent": self.client, "profile": self.profile_name, "returncode": result.returncode, "detail": detail},
|
|
@@ -343,8 +379,31 @@ class CodingCliExecutor(Executor):
|
|
|
343
379
|
task_id=task.id,
|
|
344
380
|
summary=f"{self.client} finished for {task.id}",
|
|
345
381
|
)
|
|
382
|
+
# Opt-in pre-verify scope gate: this CLI subprocess wrote directly to disk with
|
|
383
|
+
# no per-write hook, so revert any out-of-scope change now (before it reaches the
|
|
384
|
+
# verify gate or a commit) rather than only flagging it as orphan_diff post-verify.
|
|
385
|
+
if self._scope_enforcement_enabled():
|
|
386
|
+
reverted = self._enforce_file_scope(task)
|
|
387
|
+
if reverted:
|
|
388
|
+
files = ", ".join(path for path, _ in reverted)
|
|
389
|
+
TraceLogger(self.project_root).log_event(
|
|
390
|
+
"agent_scope_violation_reverted",
|
|
391
|
+
{"agent": self.client, "task_id": task.id,
|
|
392
|
+
"reverted": [{"path": p, "reason": r} for p, r in reverted]},
|
|
393
|
+
run_id=run_id,
|
|
394
|
+
task_id=task.id,
|
|
395
|
+
summary=f"Reverted {len(reverted)} out-of-scope change(s) by {self.client}",
|
|
396
|
+
)
|
|
397
|
+
return ExecutionResult(
|
|
398
|
+
success=False,
|
|
399
|
+
message=(
|
|
400
|
+
f"Reverted {len(reverted)} out-of-scope file change(s) the task did not "
|
|
401
|
+
f"authorize: {files}. Re-run keeping edits within the task's allowed files."
|
|
402
|
+
),
|
|
403
|
+
)
|
|
346
404
|
return ExecutionResult(success=True, message=f"{self.client} execution finished.")
|
|
347
405
|
except subprocess.TimeoutExpired:
|
|
406
|
+
logger.error("%s timed out after %ss for %s", self.client, self._effective_timeout(), task.id)
|
|
348
407
|
self._update_run_manifest(
|
|
349
408
|
run_id,
|
|
350
409
|
status="timeout",
|
|
@@ -363,6 +422,7 @@ class CodingCliExecutor(Executor):
|
|
|
363
422
|
message=f"{self.client} execution timed out after {self._effective_timeout()}s.",
|
|
364
423
|
)
|
|
365
424
|
except Exception as exc:
|
|
425
|
+
logger.exception("%s execution raised for %s: %s", self.client, task.id, exc)
|
|
366
426
|
self._update_run_manifest(
|
|
367
427
|
run_id,
|
|
368
428
|
status="failed",
|
|
@@ -372,6 +432,71 @@ class CodingCliExecutor(Executor):
|
|
|
372
432
|
duration_seconds=round(time.monotonic() - started, 3),
|
|
373
433
|
)
|
|
374
434
|
return ExecutionResult(success=False, message=str(exc))
|
|
435
|
+
finally:
|
|
436
|
+
run_log_cm.__exit__(None, None, None)
|
|
437
|
+
|
|
438
|
+
def _scope_enforcement_enabled(self) -> bool:
|
|
439
|
+
try:
|
|
440
|
+
from devcouncil.app.config import load_config
|
|
441
|
+
return bool(load_config(self.project_root).execution.enforce_file_scope_pre_verify)
|
|
442
|
+
except Exception:
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
def _enforce_file_scope(self, task: Task) -> list[tuple[str, str]]:
|
|
446
|
+
"""Revert any file this task's subprocess changed that the task does not authorize.
|
|
447
|
+
|
|
448
|
+
Uses the task's net changed files (baseline/snapshot subtracted, DevCouncil-managed
|
|
449
|
+
paths already filtered) and the same policy the hook path enforces. Returns the list
|
|
450
|
+
of ``(path, reason)`` reverted; empty when every change was in scope."""
|
|
451
|
+
try:
|
|
452
|
+
from devcouncil.execution.policy_engine import TaskPolicyEngine
|
|
453
|
+
from devcouncil.verification.verifier import Verifier
|
|
454
|
+
|
|
455
|
+
changed = Verifier(self.project_root).get_task_changed_files(task.id)
|
|
456
|
+
except Exception:
|
|
457
|
+
return []
|
|
458
|
+
engine = TaskPolicyEngine(self.project_root)
|
|
459
|
+
reverted: list[tuple[str, str]] = []
|
|
460
|
+
for path in changed:
|
|
461
|
+
# Never touch DevCouncil-managed scaffolding even if it surfaces in the diff
|
|
462
|
+
# (e.g. baseline snapshots failed to load): `dev` owns these files, not the task.
|
|
463
|
+
if path in _SCAFFOLDING_PATHS or path.startswith(".devcouncil/"):
|
|
464
|
+
continue
|
|
465
|
+
try:
|
|
466
|
+
decision = engine.evaluate_file_change(path, task, "write")
|
|
467
|
+
except Exception:
|
|
468
|
+
continue
|
|
469
|
+
if decision.action == "deny" and self._revert_path(path):
|
|
470
|
+
reverted.append((path, decision.reason))
|
|
471
|
+
return reverted
|
|
472
|
+
|
|
473
|
+
def _revert_path(self, rel_path: str) -> bool:
|
|
474
|
+
"""Undo an out-of-scope change. A file that exists in HEAD is restored to HEAD; a
|
|
475
|
+
file the task newly added (absent from HEAD, or no HEAD at all) is unstaged and
|
|
476
|
+
deleted. Best-effort — a failed revert returns False so the path is not reported as
|
|
477
|
+
cleanly gated (and the caller does not claim it was reverted)."""
|
|
478
|
+
try:
|
|
479
|
+
in_head = subprocess.run(
|
|
480
|
+
["git", "cat-file", "-e", f"HEAD:{rel_path}"],
|
|
481
|
+
cwd=self.project_root, capture_output=True, text=True,
|
|
482
|
+
).returncode == 0
|
|
483
|
+
if in_head:
|
|
484
|
+
return subprocess.run(
|
|
485
|
+
["git", "checkout", "HEAD", "--", rel_path],
|
|
486
|
+
cwd=self.project_root, capture_output=True, text=True,
|
|
487
|
+
).returncode == 0
|
|
488
|
+
# New file (incl. the no-HEAD case): unstage if staged, then remove the working
|
|
489
|
+
# copy so it cannot be committed by the next repair attempt.
|
|
490
|
+
subprocess.run(
|
|
491
|
+
["git", "rm", "-f", "--cached", "--ignore-unmatch", rel_path],
|
|
492
|
+
cwd=self.project_root, capture_output=True, text=True,
|
|
493
|
+
)
|
|
494
|
+
full = self.project_root / rel_path
|
|
495
|
+
if full.is_file():
|
|
496
|
+
full.unlink()
|
|
497
|
+
return True
|
|
498
|
+
except Exception:
|
|
499
|
+
return False
|
|
375
500
|
|
|
376
501
|
def _resolve_invocation(self, invocation: list[str], env: dict[str, str]) -> list[str]:
|
|
377
502
|
"""Route Windows batch shims through the command interpreter.
|
|
@@ -520,7 +645,10 @@ class CodingCliExecutor(Executor):
|
|
|
520
645
|
|
|
521
646
|
def _cursor_resume_mode(self) -> str:
|
|
522
647
|
try:
|
|
523
|
-
|
|
648
|
+
if self._config is None:
|
|
649
|
+
mode = "off"
|
|
650
|
+
else:
|
|
651
|
+
mode = (self._config.execution.cursor_resume_mode or "off").strip().lower()
|
|
524
652
|
except Exception:
|
|
525
653
|
mode = "off"
|
|
526
654
|
if mode not in {"off", "project", "task"}:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import subprocess
|
|
2
3
|
import sys
|
|
3
4
|
from pathlib import Path
|
|
@@ -8,6 +9,7 @@ from devcouncil.execution.executor import Executor, ExecutionResult
|
|
|
8
9
|
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
9
10
|
|
|
10
11
|
console = Console()
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
11
13
|
|
|
12
14
|
class MiniSWEExecutor(Executor):
|
|
13
15
|
def __init__(self, project_root: Path):
|
|
@@ -22,6 +24,7 @@ class MiniSWEExecutor(Executor):
|
|
|
22
24
|
instruction_file.parent.mkdir(parents=True, exist_ok=True)
|
|
23
25
|
instruction_file.write_text(task_prompt, encoding="utf-8")
|
|
24
26
|
|
|
27
|
+
logger.info("mini-SWE-agent starting for %s", task.id)
|
|
25
28
|
console.print(f"Starting [bold]mini-SWE-agent[/bold] for task {task.id}...")
|
|
26
29
|
|
|
27
30
|
# In a real implementation, we'd invoke the agent CLI
|
|
@@ -50,10 +53,13 @@ class MiniSWEExecutor(Executor):
|
|
|
50
53
|
)
|
|
51
54
|
self._write_log(task.id, result)
|
|
52
55
|
if result.returncode != 0:
|
|
56
|
+
logger.error("mini-SWE-agent exited %s for %s", result.returncode, task.id)
|
|
53
57
|
console.print(f"[red]mini-SWE-agent exited with {result.returncode}.[/red]")
|
|
54
58
|
return ExecutionResult(success=False, message='Execution failed')
|
|
59
|
+
logger.info("mini-SWE-agent finished for %s", task.id)
|
|
55
60
|
return ExecutionResult(success=True, message='Execution successful')
|
|
56
61
|
except Exception as e:
|
|
62
|
+
logger.exception("mini-SWE-agent error for %s: %s", task.id, e)
|
|
57
63
|
console.print(f"[red]Error running mini-SWE-agent: {e}[/red]")
|
|
58
64
|
return ExecutionResult(success=False, message='Execution failed')
|
|
59
65
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from typing import List, Dict, Any
|
|
2
2
|
import asyncio
|
|
3
|
+
import logging
|
|
3
4
|
from rich.console import Console
|
|
4
5
|
from pydantic import BaseModel
|
|
5
6
|
from devcouncil.domain.task import Task
|
|
@@ -13,6 +14,7 @@ from devcouncil.execution.paths import resolve_project_path
|
|
|
13
14
|
from devcouncil.app.errors import ExecutionError
|
|
14
15
|
|
|
15
16
|
console = Console()
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
16
18
|
|
|
17
19
|
# Resilience bounds for the preview native loop.
|
|
18
20
|
MAX_AGENT_STEPS = 10
|
|
@@ -44,6 +46,7 @@ class NativeAgent(Executor):
|
|
|
44
46
|
return asyncio.run(self._run_task_async(task, requirements))
|
|
45
47
|
|
|
46
48
|
async def _run_task_async(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
49
|
+
logger.info("Native agent starting for %s (max_steps=%d)", task.id, MAX_AGENT_STEPS)
|
|
47
50
|
console.print(f"Starting [bold]Native Executor[/bold] for task {task.id}...")
|
|
48
51
|
console.print("[yellow]Native executor is preview quality; DevCouncil verification remains the completion gate.[/yellow]")
|
|
49
52
|
|
|
@@ -96,8 +99,10 @@ Rules:
|
|
|
96
99
|
# native_agent has no fallback by design, so handle it here rather than
|
|
97
100
|
# letting it propagate and abort the entire `dev go` run.
|
|
98
101
|
structured_failures += 1
|
|
102
|
+
logger.warning("Native agent step %d: unparseable action (%d/%d): %s", step + 1, structured_failures, MAX_STRUCTURED_FAILURES, exc)
|
|
99
103
|
console.print(f"[red]Native agent could not parse a valid action: {exc}[/red]")
|
|
100
104
|
if structured_failures >= MAX_STRUCTURED_FAILURES:
|
|
105
|
+
logger.error("Native agent giving up on %s after %d unparseable responses", task.id, structured_failures)
|
|
101
106
|
return ExecutionResult(
|
|
102
107
|
success=False,
|
|
103
108
|
message=f"Native agent gave up after {structured_failures} unparseable responses.",
|
|
@@ -113,6 +118,11 @@ Rules:
|
|
|
113
118
|
continue
|
|
114
119
|
structured_failures = 0
|
|
115
120
|
|
|
121
|
+
logger.info(
|
|
122
|
+
"Native agent %s step %d/%d: %d tool call(s)%s",
|
|
123
|
+
task.id, step + 1, MAX_AGENT_STEPS, len(action.tool_calls),
|
|
124
|
+
" finish=True" if action.finish else "",
|
|
125
|
+
)
|
|
116
126
|
console.print(f"\n[bold]Step {step+1}:[/bold] {action.thought}")
|
|
117
127
|
|
|
118
128
|
# Record the agent's own turn so subsequent steps see what it already did.
|
|
@@ -121,6 +131,7 @@ Rules:
|
|
|
121
131
|
messages.append({"role": "assistant", "content": action.model_dump_json()})
|
|
122
132
|
|
|
123
133
|
if action.finish:
|
|
134
|
+
logger.info("Native agent signaled completion for %s at step %d", task.id, step + 1)
|
|
124
135
|
console.print("[green]Native agent signaled completion.[/green]")
|
|
125
136
|
return ExecutionResult(success=True, message="Agent signaled completion; pending DevCouncil verification")
|
|
126
137
|
|
|
@@ -135,6 +146,7 @@ Rules:
|
|
|
135
146
|
|
|
136
147
|
for tool_call in action.tool_calls:
|
|
137
148
|
result_summary = ""
|
|
149
|
+
logger.debug("Native agent tool call: %s args=%s", tool_call.tool, list(tool_call.args))
|
|
138
150
|
try:
|
|
139
151
|
if tool_call.tool == "read_file":
|
|
140
152
|
path = tool_call.args["path"]
|
|
@@ -183,10 +195,12 @@ Rules:
|
|
|
183
195
|
|
|
184
196
|
messages.append({"role": "user", "content": f"[Tool Result] '{tool_call.tool}': {result_summary}"})
|
|
185
197
|
except Exception as e:
|
|
198
|
+
logger.warning("Native agent tool %s failed for %s: %s", tool_call.tool, task.id, e)
|
|
186
199
|
console.print(f"[red]Error executing tool {tool_call.tool}: {e}[/red]")
|
|
187
200
|
if tool_call.tool == "apply_patch":
|
|
188
201
|
consecutive_patch_failures += 1
|
|
189
202
|
if consecutive_patch_failures >= MAX_CONSECUTIVE_PATCH_FAILURES:
|
|
203
|
+
logger.error("Native agent giving up on %s after %d consecutive patch failures", task.id, consecutive_patch_failures)
|
|
190
204
|
return ExecutionResult(
|
|
191
205
|
success=False,
|
|
192
206
|
message=(
|
|
@@ -204,5 +218,6 @@ Rules:
|
|
|
204
218
|
else:
|
|
205
219
|
messages.append({"role": "user", "content": f"[Tool Error] '{tool_call.tool}' failed: {e}"})
|
|
206
220
|
|
|
221
|
+
logger.warning("Native agent reached max step limit (%d) for %s", MAX_AGENT_STEPS, task.id)
|
|
207
222
|
console.print("[red]Native agent reached maximum step limit.[/red]")
|
|
208
223
|
return ExecutionResult(success=False, message="Reached maximum step limit")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import subprocess
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from rich.console import Console
|
|
@@ -7,6 +8,7 @@ from devcouncil.execution.executor import Executor, ExecutionResult
|
|
|
7
8
|
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
8
9
|
|
|
9
10
|
console = Console()
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
10
12
|
|
|
11
13
|
class OpenHandsExecutor(Executor):
|
|
12
14
|
def __init__(self, project_root: Path):
|
|
@@ -16,6 +18,7 @@ class OpenHandsExecutor(Executor):
|
|
|
16
18
|
builder = PromptBuilder(self.project_root)
|
|
17
19
|
task_prompt = builder.build_task_prompt(task, requirements)
|
|
18
20
|
|
|
21
|
+
logger.info("OpenHands starting for %s", task.id)
|
|
19
22
|
console.print(f"Starting [bold]OpenHands[/bold] for task {task.id}...")
|
|
20
23
|
|
|
21
24
|
# OpenHands often expects a workspace mount and an instruction.
|
|
@@ -48,10 +51,13 @@ class OpenHandsExecutor(Executor):
|
|
|
48
51
|
)
|
|
49
52
|
self._write_log(task.id, result)
|
|
50
53
|
if result.returncode != 0:
|
|
54
|
+
logger.error("OpenHands exited %s for %s", result.returncode, task.id)
|
|
51
55
|
console.print(f"[red]OpenHands exited with {result.returncode}.[/red]")
|
|
52
56
|
return ExecutionResult(success=False, message=f"Exited with code {result.returncode}")
|
|
57
|
+
logger.info("OpenHands finished for %s", task.id)
|
|
53
58
|
return ExecutionResult(success=True, message="Completed successfully")
|
|
54
59
|
except Exception as e:
|
|
60
|
+
logger.exception("OpenHands error for %s: %s", task.id, e)
|
|
55
61
|
console.print(f"[red]Error running OpenHands: {e}[/red]")
|
|
56
62
|
return ExecutionResult(success=False, message=str(e))
|
|
57
63
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import re
|
|
2
3
|
from typing import List
|
|
3
4
|
from devcouncil.domain.gap import Gap
|
|
4
5
|
from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
|
|
5
6
|
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
6
9
|
# Captures the new-file starting line from a unified-diff hunk header (@@ -a,b +c,d @@).
|
|
7
10
|
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
8
11
|
|
|
@@ -32,6 +35,10 @@ class SecretScanner:
|
|
|
32
35
|
for key_type, pattern in SECRET_PATTERNS.items():
|
|
33
36
|
if pattern.search(line):
|
|
34
37
|
counter += 1
|
|
38
|
+
logger.warning(
|
|
39
|
+
"Potential %s secret detected in %s:%d (task %s)",
|
|
40
|
+
key_type, current_file, new_line_no, task_id,
|
|
41
|
+
)
|
|
35
42
|
gaps.append(Gap(
|
|
36
43
|
id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{new_line_no}-{counter}",
|
|
37
44
|
severity="critical",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections import deque
|
|
1
3
|
from pydantic import BaseModel
|
|
2
4
|
from typing import Any, List, Optional
|
|
3
5
|
from pathlib import Path
|
|
@@ -11,6 +13,32 @@ from devcouncil.gating.checks.requirement_coverage import RequirementCoverageChe
|
|
|
11
13
|
from devcouncil.gating.checks.planned_files_check import PlannedFilesCheck
|
|
12
14
|
from devcouncil.gating.checks.clean_git import CleanGitCheck
|
|
13
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _log_gate(name: str, gaps: List[Gap], *, routine: bool = False, **context: Any) -> bool:
|
|
20
|
+
"""Log a gate decision and return whether it passed (no blocking gaps).
|
|
21
|
+
|
|
22
|
+
A passing gate is logged at INFO for once-per-plan checks (a real milestone) but at
|
|
23
|
+
DEBUG for ``routine`` per-task checks (e.g. task_ready, which fires for every task and
|
|
24
|
+
every repair attempt) so the ``-v`` stream stays milestone-level. A FAILED gate is
|
|
25
|
+
always WARNING — that's the signal you actually chase.
|
|
26
|
+
"""
|
|
27
|
+
blocking = [g for g in gaps if g.blocking]
|
|
28
|
+
passed = not blocking
|
|
29
|
+
suffix = "".join(f" {k}={v}" for k, v in context.items())
|
|
30
|
+
if passed:
|
|
31
|
+
log = logger.debug if routine else logger.info
|
|
32
|
+
log("Gate %s PASSED (%d advisory gap(s))%s", name, len(gaps), suffix)
|
|
33
|
+
else:
|
|
34
|
+
logger.warning(
|
|
35
|
+
"Gate %s FAILED%s: %s",
|
|
36
|
+
name, suffix,
|
|
37
|
+
"; ".join(f"{g.gap_type}: {g.description}" for g in blocking),
|
|
38
|
+
)
|
|
39
|
+
return passed
|
|
40
|
+
|
|
41
|
+
|
|
14
42
|
class GateResult(BaseModel):
|
|
15
43
|
passed: bool
|
|
16
44
|
gaps: List[Gap]
|
|
@@ -60,10 +88,10 @@ def topological_order(tasks: List[Task]) -> List[Task]:
|
|
|
60
88
|
indegree[task.id] += 1
|
|
61
89
|
dependents[dep].append(task.id)
|
|
62
90
|
# Kahn's algorithm, seeded in original order for stability.
|
|
63
|
-
ready =
|
|
91
|
+
ready = deque(t.id for t in tasks if indegree[t.id] == 0)
|
|
64
92
|
ordered: List[str] = []
|
|
65
93
|
while ready:
|
|
66
|
-
current = ready.
|
|
94
|
+
current = ready.popleft()
|
|
67
95
|
ordered.append(current)
|
|
68
96
|
for child in dependents[current]:
|
|
69
97
|
indegree[child] -= 1
|
|
@@ -192,13 +220,16 @@ class GatePolicy:
|
|
|
192
220
|
# earlier one (e.g. both add the same function), which then fails per-task
|
|
193
221
|
# verification. Consolidating a file's work into one task avoids this.
|
|
194
222
|
writers_by_file: dict[str, list[str]] = {}
|
|
223
|
+
writer_sets: dict[str, set[str]] = {}
|
|
195
224
|
for task in tasks:
|
|
196
225
|
for pf in task.planned_files:
|
|
197
226
|
if pf.allowed_change in ("create", "modify", "delete"):
|
|
198
227
|
path = pf.path.replace("\\", "/")
|
|
199
|
-
writers_by_file.setdefault(path, [])
|
|
200
|
-
|
|
201
|
-
|
|
228
|
+
owners = writers_by_file.setdefault(path, [])
|
|
229
|
+
seen = writer_sets.setdefault(path, set())
|
|
230
|
+
if task.id not in seen:
|
|
231
|
+
seen.add(task.id)
|
|
232
|
+
owners.append(task.id)
|
|
202
233
|
for path, owners in writers_by_file.items():
|
|
203
234
|
if len(owners) > 1:
|
|
204
235
|
gaps.append(Gap(
|
|
@@ -262,7 +293,7 @@ class GatePolicy:
|
|
|
262
293
|
))
|
|
263
294
|
|
|
264
295
|
return GateResult(
|
|
265
|
-
passed=
|
|
296
|
+
passed=_log_gate("plan_approval", gaps),
|
|
266
297
|
gaps=gaps
|
|
267
298
|
)
|
|
268
299
|
|
|
@@ -305,7 +336,7 @@ class GatePolicy:
|
|
|
305
336
|
))
|
|
306
337
|
|
|
307
338
|
return GateResult(
|
|
308
|
-
passed=
|
|
339
|
+
passed=_log_gate("task_ready", gaps, routine=True, task_id=task.id),
|
|
309
340
|
gaps=gaps
|
|
310
341
|
)
|
|
311
342
|
|