devcouncil 0.1.0 → 0.1.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/LICENSE +201 -201
- package/README.md +62 -543
- package/package.json +1 -1
- package/pyproject.toml +29 -26
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +135 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +143 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/cli/commands/artifacts.py +51 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/config.py +76 -54
- package/src/devcouncil/cli/commands/dashboard.py +26 -0
- package/src/devcouncil/cli/commands/doctor.py +86 -42
- package/src/devcouncil/cli/commands/go.py +237 -0
- package/src/devcouncil/cli/commands/hook.py +96 -29
- package/src/devcouncil/cli/commands/init.py +67 -56
- package/src/devcouncil/cli/commands/integrate.py +320 -14
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +25 -21
- package/src/devcouncil/cli/commands/plan.py +257 -206
- package/src/devcouncil/cli/commands/prompt.py +36 -33
- package/src/devcouncil/cli/commands/repair.py +72 -69
- package/src/devcouncil/cli/commands/report.py +112 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
- package/src/devcouncil/cli/commands/rollback.py +49 -47
- package/src/devcouncil/cli/commands/run.py +252 -207
- package/src/devcouncil/cli/commands/setup.py +159 -18
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/status.py +117 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +2 -1
- package/src/devcouncil/cli/commands/verify.py +158 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/main.py +42 -24
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +27 -27
- package/src/devcouncil/domain/gap.py +26 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +26 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/hook_policy.py +24 -3
- package/src/devcouncil/execution/patch.py +28 -28
- package/src/devcouncil/execution/permissions.py +44 -44
- package/src/devcouncil/execution/prompt_builder.py +23 -23
- package/src/devcouncil/execution/task_runner.py +63 -63
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/coding_cli.py +112 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +81 -81
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +50 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
- package/src/devcouncil/gating/policy.py +157 -157
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +120 -0
- package/src/devcouncil/indexing/repo_mapper.py +208 -204
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/gitnexus.py +27 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +549 -96
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +207 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +138 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +38 -38
- package/src/devcouncil/llm/provider.py +146 -125
- package/src/devcouncil/llm/router.py +111 -111
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/critique_service.py +66 -66
- package/src/devcouncil/planning/plan_service.py +46 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +44 -44
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +20 -17
- package/src/devcouncil/reporting/markdown_report.py +68 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/storage/db.py +66 -66
- package/src/devcouncil/storage/models.py +83 -83
- package/src/devcouncil/storage/repositories.py +299 -222
- package/src/devcouncil/telemetry/cost.py +34 -34
- package/src/devcouncil/telemetry/tracker.py +49 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +122 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +141 -141
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/verifier.py +319 -302
- package/uv.lock +1 -1
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
from abc import ABC, abstractmethod
|
|
2
|
-
from typing import List
|
|
3
|
-
from pydantic import BaseModel
|
|
4
|
-
from devcouncil.domain.task import Task
|
|
5
|
-
from devcouncil.domain.requirement import Requirement
|
|
6
|
-
|
|
7
|
-
class ExecutionResult(BaseModel):
|
|
8
|
-
success: bool
|
|
9
|
-
message: str
|
|
10
|
-
|
|
11
|
-
class Executor(ABC):
|
|
12
|
-
@abstractmethod
|
|
13
|
-
def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
14
|
-
"""Execute the task and return the result."""
|
|
15
|
-
pass
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from devcouncil.domain.task import Task
|
|
5
|
+
from devcouncil.domain.requirement import Requirement
|
|
6
|
+
|
|
7
|
+
class ExecutionResult(BaseModel):
|
|
8
|
+
success: bool
|
|
9
|
+
message: str
|
|
10
|
+
|
|
11
|
+
class Executor(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
14
|
+
"""Execute the task and return the result."""
|
|
15
|
+
pass
|
|
@@ -20,7 +20,7 @@ class HookDecision:
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class HookPolicy:
|
|
23
|
-
"""Policy-backed hook checks for
|
|
23
|
+
"""Policy-backed hook checks for coding CLI tool-use events."""
|
|
24
24
|
|
|
25
25
|
def __init__(self, project_root: Path | None = None):
|
|
26
26
|
self.project_root = project_root.resolve() if project_root else None
|
|
@@ -47,8 +47,28 @@ class HookPolicy:
|
|
|
47
47
|
"wrangler.toml",
|
|
48
48
|
"index.html",
|
|
49
49
|
)
|
|
50
|
-
write_tools = {
|
|
51
|
-
|
|
50
|
+
write_tools = {
|
|
51
|
+
"apply_patch",
|
|
52
|
+
"edit",
|
|
53
|
+
"edit_file",
|
|
54
|
+
"replace",
|
|
55
|
+
"write",
|
|
56
|
+
"write_file",
|
|
57
|
+
"Edit",
|
|
58
|
+
"MultiEdit",
|
|
59
|
+
"Write",
|
|
60
|
+
}
|
|
61
|
+
shell_tools = {
|
|
62
|
+
"bash",
|
|
63
|
+
"exec",
|
|
64
|
+
"exec_command",
|
|
65
|
+
"local_shell",
|
|
66
|
+
"run_command",
|
|
67
|
+
"run_shell_command",
|
|
68
|
+
"shell",
|
|
69
|
+
"shell_command",
|
|
70
|
+
"Bash",
|
|
71
|
+
}
|
|
52
72
|
|
|
53
73
|
def evaluate(self, call_data: dict[str, Any], active_task: Optional[Task]) -> HookDecision:
|
|
54
74
|
tool_name = str(call_data.get("name") or call_data.get("tool_name") or call_data.get("tool") or "")
|
|
@@ -116,6 +136,7 @@ class HookPolicy:
|
|
|
116
136
|
or arguments.get("filepath")
|
|
117
137
|
or arguments.get("filePath")
|
|
118
138
|
or arguments.get("target")
|
|
139
|
+
or arguments.get("target_file")
|
|
119
140
|
)
|
|
120
141
|
return str(value) if value else None
|
|
121
142
|
|
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from devcouncil.app.errors import ExecutionError
|
|
4
|
-
|
|
5
|
-
class PatchEngine:
|
|
6
|
-
"""Handles applying unified diff patches to the codebase."""
|
|
7
|
-
|
|
8
|
-
def __init__(self, project_root: Path):
|
|
9
|
-
self.project_root = project_root
|
|
10
|
-
|
|
11
|
-
def apply_patch(self, patch_content: str) -> bool:
|
|
12
|
-
"""Applies a git-style patch to the repository."""
|
|
13
|
-
patch_file = self.project_root / ".devcouncil" / "temp.patch"
|
|
14
|
-
patch_file.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
-
patch_file.write_text(patch_content, encoding="utf-8")
|
|
16
|
-
|
|
17
|
-
try:
|
|
18
|
-
# Using git apply for robust patch application
|
|
19
|
-
subprocess.check_call(
|
|
20
|
-
["git", "apply", "--ignore-whitespace", str(patch_file)],
|
|
21
|
-
cwd=self.project_root
|
|
22
|
-
)
|
|
23
|
-
return True
|
|
24
|
-
except subprocess.CalledProcessError as e:
|
|
25
|
-
raise ExecutionError(f"Failed to apply patch: {e}")
|
|
26
|
-
finally:
|
|
27
|
-
if patch_file.exists():
|
|
28
|
-
patch_file.unlink()
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from devcouncil.app.errors import ExecutionError
|
|
4
|
+
|
|
5
|
+
class PatchEngine:
|
|
6
|
+
"""Handles applying unified diff patches to the codebase."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, project_root: Path):
|
|
9
|
+
self.project_root = project_root
|
|
10
|
+
|
|
11
|
+
def apply_patch(self, patch_content: str) -> bool:
|
|
12
|
+
"""Applies a git-style patch to the repository."""
|
|
13
|
+
patch_file = self.project_root / ".devcouncil" / "temp.patch"
|
|
14
|
+
patch_file.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
patch_file.write_text(patch_content, encoding="utf-8")
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
# Using git apply for robust patch application
|
|
19
|
+
subprocess.check_call(
|
|
20
|
+
["git", "apply", "--ignore-whitespace", str(patch_file)],
|
|
21
|
+
cwd=self.project_root
|
|
22
|
+
)
|
|
23
|
+
return True
|
|
24
|
+
except subprocess.CalledProcessError as e:
|
|
25
|
+
raise ExecutionError(f"Failed to apply patch: {e}")
|
|
26
|
+
finally:
|
|
27
|
+
if patch_file.exists():
|
|
28
|
+
patch_file.unlink()
|
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import fnmatch
|
|
2
|
-
from pathlib import Path
|
|
1
|
+
import fnmatch
|
|
2
|
+
from pathlib import Path
|
|
3
3
|
from typing import List, Literal, Optional
|
|
4
|
-
from pydantic import BaseModel, Field
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
5
|
from devcouncil.domain.task import PlannedFile, Task
|
|
6
|
-
from devcouncil.app.errors import GatingError
|
|
7
|
-
|
|
8
|
-
class PermissionPolicy(BaseModel):
|
|
9
|
-
"""Defines the security boundaries for task execution."""
|
|
10
|
-
allow_file_create: bool = False
|
|
11
|
-
allow_file_delete: bool = False
|
|
12
|
-
allowed_shell_commands: List[str] = Field(default_factory=list)
|
|
13
|
-
restricted_paths: List[str] = Field(default_factory=lambda: [".git/*", ".devcouncil/*", ".env*"])
|
|
14
|
-
|
|
6
|
+
from devcouncil.app.errors import GatingError
|
|
7
|
+
|
|
8
|
+
class PermissionPolicy(BaseModel):
|
|
9
|
+
"""Defines the security boundaries for task execution."""
|
|
10
|
+
allow_file_create: bool = False
|
|
11
|
+
allow_file_delete: bool = False
|
|
12
|
+
allowed_shell_commands: List[str] = Field(default_factory=list)
|
|
13
|
+
restricted_paths: List[str] = Field(default_factory=lambda: [".git/*", ".devcouncil/*", ".env*"])
|
|
14
|
+
|
|
15
15
|
class PermissionManager:
|
|
16
|
-
def __init__(self, policy: PermissionPolicy, project_root: Path = Path(".")):
|
|
17
|
-
self.policy = policy
|
|
18
|
-
self.project_root = project_root
|
|
19
|
-
self.dynamic_ignores = self._load_devcouncilignore()
|
|
20
|
-
|
|
21
|
-
def _load_devcouncilignore(self) -> List[str]:
|
|
22
|
-
"""Load additional restricted paths from .devcouncilignore."""
|
|
23
|
-
ignore_file = self.project_root / ".devcouncilignore"
|
|
24
|
-
if ignore_file.exists():
|
|
25
|
-
try:
|
|
26
|
-
lines = ignore_file.read_text().splitlines()
|
|
27
|
-
return [line.strip() for line in lines if line.strip() and not line.startswith("#")]
|
|
28
|
-
except Exception:
|
|
29
|
-
pass
|
|
30
|
-
return []
|
|
31
|
-
|
|
16
|
+
def __init__(self, policy: PermissionPolicy, project_root: Path = Path(".")):
|
|
17
|
+
self.policy = policy
|
|
18
|
+
self.project_root = project_root
|
|
19
|
+
self.dynamic_ignores = self._load_devcouncilignore()
|
|
20
|
+
|
|
21
|
+
def _load_devcouncilignore(self) -> List[str]:
|
|
22
|
+
"""Load additional restricted paths from .devcouncilignore."""
|
|
23
|
+
ignore_file = self.project_root / ".devcouncilignore"
|
|
24
|
+
if ignore_file.exists():
|
|
25
|
+
try:
|
|
26
|
+
lines = ignore_file.read_text().splitlines()
|
|
27
|
+
return [line.strip() for line in lines if line.strip() and not line.startswith("#")]
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
32
|
def is_file_change_allowed(
|
|
33
33
|
self,
|
|
34
34
|
path: str,
|
|
@@ -60,19 +60,19 @@ class PermissionManager:
|
|
|
60
60
|
if normalized == planned_path or fnmatch.fnmatch(normalized, planned_path):
|
|
61
61
|
return planned
|
|
62
62
|
return None
|
|
63
|
-
|
|
64
|
-
def is_command_allowed(self, command: str, task: Task) -> bool:
|
|
65
|
-
"""Check if a shell command is authorized by the task or global allowlist."""
|
|
66
|
-
# 1. Check task-specific allowlist
|
|
67
|
-
if any(fnmatch.fnmatch(command, allowed) for allowed in task.allowed_commands):
|
|
68
|
-
return True
|
|
69
|
-
|
|
70
|
-
# 2. Check global policy allowlist
|
|
71
|
-
if any(fnmatch.fnmatch(command, allowed) for allowed in self.policy.allowed_shell_commands):
|
|
72
|
-
return True
|
|
73
|
-
|
|
74
|
-
return False
|
|
75
|
-
|
|
63
|
+
|
|
64
|
+
def is_command_allowed(self, command: str, task: Task) -> bool:
|
|
65
|
+
"""Check if a shell command is authorized by the task or global allowlist."""
|
|
66
|
+
# 1. Check task-specific allowlist
|
|
67
|
+
if any(fnmatch.fnmatch(command, allowed) for allowed in task.allowed_commands):
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
# 2. Check global policy allowlist
|
|
71
|
+
if any(fnmatch.fnmatch(command, allowed) for allowed in self.policy.allowed_shell_commands):
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
76
|
def validate_action(
|
|
77
77
|
self,
|
|
78
78
|
action_type: str,
|
|
@@ -87,6 +87,6 @@ class PermissionManager:
|
|
|
87
87
|
f"Unauthorized file {operation}: {target}. "
|
|
88
88
|
"File and operation must match task planned_files."
|
|
89
89
|
)
|
|
90
|
-
elif action_type == "shell":
|
|
91
|
-
if not self.is_command_allowed(target, task):
|
|
92
|
-
raise GatingError(f"Unauthorized shell command: {target}. Command must be in task's allowed_commands.")
|
|
90
|
+
elif action_type == "shell":
|
|
91
|
+
if not self.is_command_allowed(target, task):
|
|
92
|
+
raise GatingError(f"Unauthorized shell command: {target}. Command must be in task's allowed_commands.")
|
|
@@ -10,25 +10,25 @@ class PromptBuilder:
|
|
|
10
10
|
self.project_root = project_root
|
|
11
11
|
|
|
12
12
|
def build_task_prompt(self, task: Task, requirements: List[Requirement]) -> str:
|
|
13
|
-
req_map = {r.id: r for r in requirements}
|
|
14
|
-
task_reqs = [req_map[rid] for rid in task.requirement_ids if rid in req_map]
|
|
15
|
-
|
|
16
|
-
prompt = f"""# Implement {task.id}: {task.title}
|
|
17
|
-
|
|
18
|
-
## Goal
|
|
19
|
-
{task.description}
|
|
20
|
-
|
|
21
|
-
## Requirements
|
|
22
|
-
"""
|
|
23
|
-
for req in task_reqs:
|
|
24
|
-
prompt += f"- {req.id}: {req.title}\n"
|
|
25
|
-
for ac in req.acceptance_criteria:
|
|
26
|
-
prompt += f" - [ ] {ac.description} ({ac.verification_method})\n"
|
|
27
|
-
|
|
13
|
+
req_map = {r.id: r for r in requirements}
|
|
14
|
+
task_reqs = [req_map[rid] for rid in task.requirement_ids if rid in req_map]
|
|
15
|
+
|
|
16
|
+
prompt = f"""# Implement {task.id}: {task.title}
|
|
17
|
+
|
|
18
|
+
## Goal
|
|
19
|
+
{task.description}
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
"""
|
|
23
|
+
for req in task_reqs:
|
|
24
|
+
prompt += f"- {req.id}: {req.title}\n"
|
|
25
|
+
for ac in req.acceptance_criteria:
|
|
26
|
+
prompt += f" - [ ] {ac.description} ({ac.verification_method})\n"
|
|
27
|
+
|
|
28
28
|
prompt += "\n## Allowed files\n"
|
|
29
29
|
for pf in task.planned_files:
|
|
30
30
|
prompt += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
if task.forbidden_changes:
|
|
33
33
|
prompt += "\n## Forbidden changes\n"
|
|
34
34
|
for fc in task.forbidden_changes:
|
|
@@ -50,10 +50,10 @@ class PromptBuilder:
|
|
|
50
50
|
|
|
51
51
|
prompt += """
|
|
52
52
|
## Instructions
|
|
53
|
-
1. Implement the goal described above.
|
|
54
|
-
2. Ensure all acceptance criteria are met.
|
|
55
|
-
3. Only modify the allowed files.
|
|
56
|
-
4. Run the allowed commands to verify your work.
|
|
57
|
-
5. Provide evidence of passing tests.
|
|
58
|
-
"""
|
|
59
|
-
return prompt
|
|
53
|
+
1. Implement the goal described above.
|
|
54
|
+
2. Ensure all acceptance criteria are met.
|
|
55
|
+
3. Only modify the allowed files.
|
|
56
|
+
4. Run the allowed commands to verify your work.
|
|
57
|
+
5. Provide evidence of passing tests.
|
|
58
|
+
"""
|
|
59
|
+
return prompt
|
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import hashlib
|
|
2
|
-
import subprocess
|
|
3
|
-
import logging
|
|
4
|
-
import re
|
|
5
|
-
import shlex
|
|
6
|
-
from pathlib import Path
|
|
1
|
+
import hashlib
|
|
2
|
+
import subprocess
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
7
|
from typing import Literal, Optional
|
|
8
|
-
from devcouncil.domain.task import Task
|
|
9
|
-
from devcouncil.execution.permissions import PermissionManager
|
|
10
|
-
from devcouncil.domain.evidence import CommandResult
|
|
8
|
+
from devcouncil.domain.task import Task
|
|
9
|
+
from devcouncil.execution.permissions import PermissionManager
|
|
10
|
+
from devcouncil.domain.evidence import CommandResult
|
|
11
11
|
from devcouncil.app.errors import ExecutionError
|
|
12
12
|
|
|
13
13
|
from devcouncil.execution.patch import PatchEngine
|
|
14
14
|
from devcouncil.execution.paths import resolve_project_path
|
|
15
15
|
from devcouncil.telemetry.traces import TraceLogger
|
|
16
16
|
from devcouncil.utils.redaction import redact_string
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
|
|
20
|
-
class TaskRunner:
|
|
21
|
-
"""Safely executes task actions while enforcing permission boundaries."""
|
|
22
|
-
|
|
23
|
-
def __init__(self, project_root: Path, permission_manager: PermissionManager):
|
|
24
|
-
self.project_root = project_root
|
|
25
|
-
self.permissions = permission_manager
|
|
26
|
-
self.patch_engine = PatchEngine(project_root)
|
|
27
|
-
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
class TaskRunner:
|
|
21
|
+
"""Safely executes task actions while enforcing permission boundaries."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, project_root: Path, permission_manager: PermissionManager):
|
|
24
|
+
self.project_root = project_root
|
|
25
|
+
self.permissions = permission_manager
|
|
26
|
+
self.patch_engine = PatchEngine(project_root)
|
|
27
|
+
|
|
28
28
|
def _validate_path_within_root(self, path: str) -> None:
|
|
29
29
|
"""Ensure a path resolves to a location within the project root."""
|
|
30
30
|
resolve_project_path(self.project_root, path)
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
def apply_patch(self, patch: str, task: Task) -> bool:
|
|
33
33
|
"""Apply a patch if permissions allow (all affected files must be in planned_files)."""
|
|
34
34
|
changes = self._extract_patch_changes(patch)
|
|
@@ -81,48 +81,48 @@ class TaskRunner:
|
|
|
81
81
|
if not changes:
|
|
82
82
|
raise ExecutionError("Patch does not declare any affected files.")
|
|
83
83
|
return changes
|
|
84
|
-
|
|
85
|
-
def _normalize_patch_path(self, raw_path: str) -> Optional[str]:
|
|
86
|
-
raw_path = raw_path.strip().strip('"')
|
|
87
|
-
if raw_path == "/dev/null":
|
|
88
|
-
return None
|
|
89
|
-
raw_path = re.sub(r"^[ab]/", "", raw_path)
|
|
90
|
-
return raw_path.replace("\\", "/")
|
|
91
|
-
|
|
92
|
-
def _save_command_log(self, task_id: str, command: str, stream: str, content: str) -> str:
|
|
93
|
-
"""Save command output to a log file and return the path."""
|
|
94
|
-
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
95
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
-
cmd_hash = hashlib.sha256(command.encode()).hexdigest()[:8]
|
|
84
|
+
|
|
85
|
+
def _normalize_patch_path(self, raw_path: str) -> Optional[str]:
|
|
86
|
+
raw_path = raw_path.strip().strip('"')
|
|
87
|
+
if raw_path == "/dev/null":
|
|
88
|
+
return None
|
|
89
|
+
raw_path = re.sub(r"^[ab]/", "", raw_path)
|
|
90
|
+
return raw_path.replace("\\", "/")
|
|
91
|
+
|
|
92
|
+
def _save_command_log(self, task_id: str, command: str, stream: str, content: str) -> str:
|
|
93
|
+
"""Save command output to a log file and return the path."""
|
|
94
|
+
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
95
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
cmd_hash = hashlib.sha256(command.encode()).hexdigest()[:8]
|
|
97
97
|
filename = f"{task_id}-{cmd_hash}-{stream}.log"
|
|
98
98
|
log_path = log_dir / filename
|
|
99
99
|
log_path.write_text(redact_string(content), encoding="utf-8")
|
|
100
100
|
return str(log_path)
|
|
101
|
-
|
|
102
|
-
def run_command(self, command: str, task: Task) -> CommandResult:
|
|
103
|
-
"""Execute a shell command if allowed by permissions."""
|
|
104
|
-
self.permissions.validate_action("shell", command, task)
|
|
105
|
-
|
|
106
|
-
logger.info(f"Executing authorized command: {command}")
|
|
107
|
-
|
|
108
|
-
try:
|
|
109
|
-
from devcouncil.app.config import load_config
|
|
110
|
-
config = load_config(self.project_root)
|
|
111
|
-
timeout = config.execution.command_timeout
|
|
112
|
-
except Exception:
|
|
113
|
-
timeout = 300
|
|
114
|
-
|
|
115
|
-
try:
|
|
116
|
-
result = subprocess.run(
|
|
117
|
-
shlex.split(command, posix=False),
|
|
118
|
-
shell=False,
|
|
119
|
-
capture_output=True,
|
|
120
|
-
text=True,
|
|
121
|
-
encoding="utf-8",
|
|
122
|
-
errors="replace",
|
|
123
|
-
cwd=self.project_root,
|
|
124
|
-
timeout=timeout
|
|
125
|
-
)
|
|
101
|
+
|
|
102
|
+
def run_command(self, command: str, task: Task) -> CommandResult:
|
|
103
|
+
"""Execute a shell command if allowed by permissions."""
|
|
104
|
+
self.permissions.validate_action("shell", command, task)
|
|
105
|
+
|
|
106
|
+
logger.info(f"Executing authorized command: {command}")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
from devcouncil.app.config import load_config
|
|
110
|
+
config = load_config(self.project_root)
|
|
111
|
+
timeout = config.execution.command_timeout
|
|
112
|
+
except Exception:
|
|
113
|
+
timeout = 300
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
shlex.split(command, posix=False),
|
|
118
|
+
shell=False,
|
|
119
|
+
capture_output=True,
|
|
120
|
+
text=True,
|
|
121
|
+
encoding="utf-8",
|
|
122
|
+
errors="replace",
|
|
123
|
+
cwd=self.project_root,
|
|
124
|
+
timeout=timeout
|
|
125
|
+
)
|
|
126
126
|
stdout = result.stdout or ""
|
|
127
127
|
stderr = result.stderr or ""
|
|
128
128
|
stdout_path = self._save_command_log(task.id, command, "stdout", stdout)
|
|
@@ -136,15 +136,15 @@ class TaskRunner:
|
|
|
136
136
|
summary=f"{command} exited {result.returncode}",
|
|
137
137
|
)
|
|
138
138
|
return CommandResult(
|
|
139
|
-
command=command,
|
|
139
|
+
command=command,
|
|
140
140
|
exit_code=result.returncode,
|
|
141
141
|
stdout_path=stdout_path,
|
|
142
142
|
stderr_path=stderr_path,
|
|
143
143
|
summary=f"Exit code {result.returncode}. stdout: {stdout_summary}. stderr: {stderr_summary}"
|
|
144
144
|
)
|
|
145
|
-
except Exception as e:
|
|
146
|
-
raise ExecutionError(f"Command execution failed: {e}")
|
|
147
|
-
|
|
145
|
+
except Exception as e:
|
|
146
|
+
raise ExecutionError(f"Command execution failed: {e}")
|
|
147
|
+
|
|
148
148
|
def write_file(self, path: str, content: str, task: Task):
|
|
149
149
|
"""Write content to a file if allowed by permissions."""
|
|
150
150
|
self._validate_path_within_root(path)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from devcouncil.domain.requirement import Requirement
|
|
9
|
+
from devcouncil.domain.task import Task
|
|
10
|
+
from devcouncil.execution.executor import Executor, ExecutionResult
|
|
11
|
+
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CodingCliExecutor(Executor):
|
|
17
|
+
"""Execute a DevCouncil task by handing it off to an external coding CLI."""
|
|
18
|
+
|
|
19
|
+
_ALIASES = {
|
|
20
|
+
"codex-cli": "codex",
|
|
21
|
+
"gemini-cli": "gemini",
|
|
22
|
+
"claude-cli": "claude",
|
|
23
|
+
"claude-code": "claude",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def __init__(self, project_root: Path, client: str, timeout_seconds: int = 1800):
|
|
27
|
+
self.client = self._normalize_client(client)
|
|
28
|
+
self.project_root = project_root
|
|
29
|
+
self.timeout_seconds = timeout_seconds
|
|
30
|
+
|
|
31
|
+
def _normalize_client(self, client: str) -> str:
|
|
32
|
+
normalized = (client or "").strip().lower().replace("_", "-")
|
|
33
|
+
return self._ALIASES.get(normalized, normalized)
|
|
34
|
+
|
|
35
|
+
def _command(self) -> list[str]:
|
|
36
|
+
if self.client == "codex":
|
|
37
|
+
return ["codex", "exec", "-"]
|
|
38
|
+
if self.client == "gemini":
|
|
39
|
+
return ["gemini"]
|
|
40
|
+
if self.client == "claude":
|
|
41
|
+
return ["claude", "-p"]
|
|
42
|
+
raise ValueError(f"Unsupported coding CLI client: {self.client}")
|
|
43
|
+
|
|
44
|
+
def run_task(self, task: Task, requirements: list[Requirement]) -> ExecutionResult:
|
|
45
|
+
try:
|
|
46
|
+
command = self._command()
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
return ExecutionResult(success=False, message=str(exc))
|
|
49
|
+
|
|
50
|
+
executable = command[0]
|
|
51
|
+
if not shutil.which(executable):
|
|
52
|
+
return ExecutionResult(
|
|
53
|
+
success=False,
|
|
54
|
+
message=f"{self.client} CLI is not installed or not on PATH.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
prompt = PromptBuilder(self.project_root).build_task_prompt(task, requirements)
|
|
58
|
+
instruction_file = self.project_root / ".devcouncil" / f"{task.id}-{self.client}-task.md"
|
|
59
|
+
instruction_file.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
instruction_file.write_text(prompt, encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
env = {**dict(os.environ), "DEVCOUNCIL_PROJECT_ROOT": str(self.project_root)}
|
|
63
|
+
log_prefix = f"{task.id}-{self.client}"
|
|
64
|
+
|
|
65
|
+
console.print(f"Starting [bold]{self.client.upper()}[/bold] for task [bold]{task.id}[/bold]...")
|
|
66
|
+
console.print(f"Task prompt: [dim]{instruction_file}[/dim]")
|
|
67
|
+
console.print(f"Command: [dim]{' '.join(command)}[/dim]")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
command,
|
|
72
|
+
input=prompt,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
encoding="utf-8",
|
|
76
|
+
errors="replace",
|
|
77
|
+
cwd=self.project_root,
|
|
78
|
+
env=env,
|
|
79
|
+
timeout=self.timeout_seconds,
|
|
80
|
+
)
|
|
81
|
+
self._write_log(log_prefix, result)
|
|
82
|
+
if result.returncode != 0:
|
|
83
|
+
stderr_preview = (result.stderr or result.stdout or "").strip().splitlines()[:5]
|
|
84
|
+
detail = stderr_preview[0] if stderr_preview else "No diagnostics were produced."
|
|
85
|
+
return ExecutionResult(
|
|
86
|
+
success=False,
|
|
87
|
+
message=f"{self.client} exited with code {result.returncode}: {detail}",
|
|
88
|
+
)
|
|
89
|
+
return ExecutionResult(success=True, message=f"{self.client} execution finished.")
|
|
90
|
+
except subprocess.TimeoutExpired as exc:
|
|
91
|
+
_ = exc
|
|
92
|
+
return ExecutionResult(
|
|
93
|
+
success=False,
|
|
94
|
+
message=f"{self.client} execution timed out after {self.timeout_seconds}s.",
|
|
95
|
+
)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
return ExecutionResult(success=False, message=str(exc))
|
|
98
|
+
|
|
99
|
+
def _write_log(self, task_client: str, result: subprocess.CompletedProcess[str]) -> None:
|
|
100
|
+
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
101
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
log_path = log_dir / f"{task_client}.log"
|
|
103
|
+
log_path.write_text(
|
|
104
|
+
"\n".join([
|
|
105
|
+
f"command_returncode={result.returncode}",
|
|
106
|
+
"=== stdout ===",
|
|
107
|
+
result.stdout or "",
|
|
108
|
+
"=== stderr ===",
|
|
109
|
+
result.stderr or "",
|
|
110
|
+
]),
|
|
111
|
+
encoding="utf-8",
|
|
112
|
+
)
|