devcouncil 0.1.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/LICENSE +201 -0
- package/README.md +643 -0
- package/bin/devcouncil.js +62 -0
- package/package.json +47 -0
- package/pyproject.toml +31 -0
- package/src/devcouncil/__init__.py +0 -0
- package/src/devcouncil/__main__.py +4 -0
- package/src/devcouncil/app/__init__.py +28 -0
- package/src/devcouncil/app/config.py +131 -0
- package/src/devcouncil/app/errors.py +23 -0
- package/src/devcouncil/app/events.py +44 -0
- package/src/devcouncil/app/orchestrator.py +92 -0
- package/src/devcouncil/app/run_context.py +39 -0
- package/src/devcouncil/app/state_machine.py +108 -0
- package/src/devcouncil/artifacts/__init__.py +1 -0
- package/src/devcouncil/artifacts/coverage.py +96 -0
- package/src/devcouncil/artifacts/graph.py +143 -0
- package/src/devcouncil/artifacts/migrations.py +20 -0
- package/src/devcouncil/artifacts/schemas.py +23 -0
- package/src/devcouncil/artifacts/serializer.py +21 -0
- package/src/devcouncil/artifacts/validators.py +27 -0
- package/src/devcouncil/cli/__init__.py +0 -0
- package/src/devcouncil/cli/commands/__init__.py +0 -0
- package/src/devcouncil/cli/commands/artifacts.py +48 -0
- package/src/devcouncil/cli/commands/baseline.py +32 -0
- package/src/devcouncil/cli/commands/config.py +54 -0
- package/src/devcouncil/cli/commands/doctor.py +96 -0
- package/src/devcouncil/cli/commands/hook.py +61 -0
- package/src/devcouncil/cli/commands/init.py +142 -0
- package/src/devcouncil/cli/commands/integrate.py +420 -0
- package/src/devcouncil/cli/commands/map.py +38 -0
- package/src/devcouncil/cli/commands/mcp_server.py +18 -0
- package/src/devcouncil/cli/commands/plan.py +276 -0
- package/src/devcouncil/cli/commands/prompt.py +47 -0
- package/src/devcouncil/cli/commands/repair.py +69 -0
- package/src/devcouncil/cli/commands/report.py +71 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +28 -0
- package/src/devcouncil/cli/commands/rollback.py +58 -0
- package/src/devcouncil/cli/commands/run.py +224 -0
- package/src/devcouncil/cli/commands/setup.py +82 -0
- package/src/devcouncil/cli/commands/show.py +57 -0
- package/src/devcouncil/cli/commands/status.py +105 -0
- package/src/devcouncil/cli/commands/tasks.py +41 -0
- package/src/devcouncil/cli/commands/trace.py +43 -0
- package/src/devcouncil/cli/commands/verify.py +163 -0
- package/src/devcouncil/cli/commands/version.py +20 -0
- package/src/devcouncil/cli/main.py +70 -0
- package/src/devcouncil/council/__init__.py +0 -0
- package/src/devcouncil/council/prompts/__init__.py +0 -0
- package/src/devcouncil/council/prompts/arbiter.md +19 -0
- package/src/devcouncil/council/prompts/critic_a.md +10 -0
- package/src/devcouncil/council/prompts/critic_b.md +10 -0
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -0
- package/src/devcouncil/council/prompts/planner_a.md +16 -0
- package/src/devcouncil/council/prompts/planner_b.md +16 -0
- package/src/devcouncil/council/prompts/rebuttal.md +10 -0
- package/src/devcouncil/council/prompts/spec_writer.md +12 -0
- package/src/devcouncil/domain/__init__.py +0 -0
- package/src/devcouncil/domain/assumption.py +17 -0
- package/src/devcouncil/domain/critique.py +32 -0
- package/src/devcouncil/domain/evidence.py +27 -0
- package/src/devcouncil/domain/gap.py +26 -0
- package/src/devcouncil/domain/requirement.py +22 -0
- package/src/devcouncil/domain/task.py +26 -0
- package/src/devcouncil/execution/__init__.py +1 -0
- package/src/devcouncil/execution/context_builder.py +60 -0
- package/src/devcouncil/execution/executor.py +15 -0
- package/src/devcouncil/execution/hook_policy.py +144 -0
- package/src/devcouncil/execution/patch.py +28 -0
- package/src/devcouncil/execution/paths.py +14 -0
- package/src/devcouncil/execution/permissions.py +92 -0
- package/src/devcouncil/execution/prompt_builder.py +59 -0
- package/src/devcouncil/execution/task_runner.py +166 -0
- package/src/devcouncil/executors/__init__.py +1 -0
- package/src/devcouncil/executors/mini_swe.py +73 -0
- package/src/devcouncil/executors/native/__init__.py +0 -0
- package/src/devcouncil/executors/native/agent.py +107 -0
- package/src/devcouncil/executors/openhands.py +71 -0
- package/src/devcouncil/gating/__init__.py +1 -0
- package/src/devcouncil/gating/checks/__init__.py +0 -0
- package/src/devcouncil/gating/checks/clean_git.py +45 -0
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -0
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +34 -0
- package/src/devcouncil/gating/policy.py +190 -0
- package/src/devcouncil/indexing/__init__.py +1 -0
- package/src/devcouncil/indexing/graph_index.py +48 -0
- package/src/devcouncil/indexing/repo_mapper.py +204 -0
- package/src/devcouncil/indexing/symbol_index.py +0 -0
- package/src/devcouncil/integrations/code_review_graph.py +163 -0
- package/src/devcouncil/integrations/github.py +39 -0
- package/src/devcouncil/integrations/gitnexus.py +27 -0
- package/src/devcouncil/integrations/graphify.py +34 -0
- package/src/devcouncil/integrations/mcp/__init__.py +0 -0
- package/src/devcouncil/integrations/mcp/server.py +146 -0
- package/src/devcouncil/llm/__init__.py +1 -0
- package/src/devcouncil/llm/cache.py +38 -0
- package/src/devcouncil/llm/provider.py +125 -0
- package/src/devcouncil/llm/router.py +125 -0
- package/src/devcouncil/planning/__init__.py +1 -0
- package/src/devcouncil/planning/arbiter_service.py +57 -0
- package/src/devcouncil/planning/critique_service.py +66 -0
- package/src/devcouncil/planning/plan_service.py +46 -0
- package/src/devcouncil/planning/repair_service.py +39 -0
- package/src/devcouncil/planning/spec_service.py +44 -0
- package/src/devcouncil/repo/__init__.py +0 -0
- package/src/devcouncil/reporting/__init__.py +0 -0
- package/src/devcouncil/reporting/github_check.py +32 -0
- package/src/devcouncil/reporting/json_report.py +17 -0
- package/src/devcouncil/reporting/markdown_report.py +46 -0
- package/src/devcouncil/reporting/report_builder.py +14 -0
- package/src/devcouncil/storage/__init__.py +0 -0
- package/src/devcouncil/storage/db.py +66 -0
- package/src/devcouncil/storage/models.py +83 -0
- package/src/devcouncil/storage/repositories.py +346 -0
- package/src/devcouncil/telemetry/__init__.py +0 -0
- package/src/devcouncil/telemetry/cost.py +34 -0
- package/src/devcouncil/telemetry/traces.py +91 -0
- package/src/devcouncil/telemetry/tracker.py +49 -0
- package/src/devcouncil/utils/__init__.py +1 -0
- package/src/devcouncil/utils/redaction.py +141 -0
- package/src/devcouncil/verification/__init__.py +1 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -0
- package/src/devcouncil/verification/verifier.py +513 -0
- package/uv.lock +1085 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from devcouncil.domain.task import Task
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class HookDecision:
|
|
13
|
+
action: str
|
|
14
|
+
reason: str
|
|
15
|
+
target: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def allowed(self) -> bool:
|
|
19
|
+
return self.action in {"allow", "warn"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HookPolicy:
|
|
23
|
+
"""Policy-backed hook checks for Claude-style pre-tool-use events."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, project_root: Path | None = None):
|
|
26
|
+
self.project_root = project_root.resolve() if project_root else None
|
|
27
|
+
|
|
28
|
+
secret_path_patterns = (
|
|
29
|
+
".env",
|
|
30
|
+
".env.*",
|
|
31
|
+
"**/.env",
|
|
32
|
+
"**/.env.*",
|
|
33
|
+
"**/credentials/**",
|
|
34
|
+
"**/secrets/**",
|
|
35
|
+
"**/*.pem",
|
|
36
|
+
"**/*.key",
|
|
37
|
+
)
|
|
38
|
+
protected_path_patterns = (
|
|
39
|
+
"package.json",
|
|
40
|
+
"pyproject.toml",
|
|
41
|
+
"uv.lock",
|
|
42
|
+
"Dockerfile",
|
|
43
|
+
"docker-compose.yml",
|
|
44
|
+
".github/workflows/*.yml",
|
|
45
|
+
".github/workflows/*.yaml",
|
|
46
|
+
"schema.prisma",
|
|
47
|
+
"wrangler.toml",
|
|
48
|
+
"index.html",
|
|
49
|
+
)
|
|
50
|
+
write_tools = {"write_file", "edit_file", "replace", "Write", "Edit", "MultiEdit"}
|
|
51
|
+
shell_tools = {"bash", "shell", "run_command", "Bash"}
|
|
52
|
+
|
|
53
|
+
def evaluate(self, call_data: dict[str, Any], active_task: Optional[Task]) -> HookDecision:
|
|
54
|
+
tool_name = str(call_data.get("name") or call_data.get("tool_name") or call_data.get("tool") or "")
|
|
55
|
+
arguments = call_data.get("arguments") or call_data.get("input") or call_data.get("tool_input") or {}
|
|
56
|
+
if not isinstance(arguments, dict):
|
|
57
|
+
arguments = {}
|
|
58
|
+
|
|
59
|
+
if tool_name in self.shell_tools:
|
|
60
|
+
command = self._extract_command(arguments)
|
|
61
|
+
return self.evaluate_command(command)
|
|
62
|
+
|
|
63
|
+
if tool_name in self.write_tools:
|
|
64
|
+
target = self._extract_path(arguments)
|
|
65
|
+
return self.evaluate_file_write(target, active_task)
|
|
66
|
+
|
|
67
|
+
return HookDecision("allow", "Tool is outside DevCouncil hook policy.")
|
|
68
|
+
|
|
69
|
+
def evaluate_command(self, command: str) -> HookDecision:
|
|
70
|
+
normalized = " ".join(command.split())
|
|
71
|
+
lowered = normalized.lower()
|
|
72
|
+
if not normalized:
|
|
73
|
+
return HookDecision("allow", "No command detected.")
|
|
74
|
+
|
|
75
|
+
if "--no-verify" in lowered or "--no-gpg-sign" in lowered:
|
|
76
|
+
return HookDecision("deny", "Verification bypass flags are not allowed.", normalized)
|
|
77
|
+
|
|
78
|
+
if re.search(r"\bgit\s+reset\s+--hard\s+(origin/)?(main|master)\b", lowered):
|
|
79
|
+
return HookDecision("deny", "Protected branch hard resets are not allowed.", normalized)
|
|
80
|
+
|
|
81
|
+
if re.search(r"\bgit\s+push\b.*(\s--force(?:-with-lease)?\b|\s-f\b)", lowered):
|
|
82
|
+
return HookDecision("deny", "Force pushes are not allowed.", normalized)
|
|
83
|
+
|
|
84
|
+
if re.search(r"\bgit\s+push\s+\S+\s+((head:)?(main|master)|(main|master):\S+)\b", lowered):
|
|
85
|
+
return HookDecision("warn", "Direct pushes to protected branches should go through verification gates.", normalized)
|
|
86
|
+
|
|
87
|
+
return HookDecision("allow", "Command is allowed.", normalized)
|
|
88
|
+
|
|
89
|
+
def evaluate_file_write(self, raw_path: Optional[str], active_task: Optional[Task]) -> HookDecision:
|
|
90
|
+
if not raw_path:
|
|
91
|
+
return HookDecision("allow", "No file path detected.")
|
|
92
|
+
|
|
93
|
+
path = self._normalize_path(raw_path)
|
|
94
|
+
if self._matches_any(path, self.secret_path_patterns):
|
|
95
|
+
return HookDecision("deny", "Secret and credential paths are never writable through hooks.", path)
|
|
96
|
+
|
|
97
|
+
if active_task is None:
|
|
98
|
+
return HookDecision("deny", "No running DevCouncil task authorizes this file write.", path)
|
|
99
|
+
|
|
100
|
+
if active_task and not self._is_planned_file(path, active_task):
|
|
101
|
+
return HookDecision("deny", f"Task {active_task.id} does not authorize changes to {path}.", path)
|
|
102
|
+
|
|
103
|
+
if self._matches_any(path, self.protected_path_patterns):
|
|
104
|
+
return HookDecision("warn", f"{path} is a protected high-impact file; verification gates must approve it.", path)
|
|
105
|
+
|
|
106
|
+
return HookDecision("allow", "File write is allowed.", path)
|
|
107
|
+
|
|
108
|
+
def _extract_command(self, arguments: dict[str, Any]) -> str:
|
|
109
|
+
value = arguments.get("command") or arguments.get("cmd") or arguments.get("script") or ""
|
|
110
|
+
return str(value)
|
|
111
|
+
|
|
112
|
+
def _extract_path(self, arguments: dict[str, Any]) -> Optional[str]:
|
|
113
|
+
value = (
|
|
114
|
+
arguments.get("path")
|
|
115
|
+
or arguments.get("file_path")
|
|
116
|
+
or arguments.get("filepath")
|
|
117
|
+
or arguments.get("filePath")
|
|
118
|
+
or arguments.get("target")
|
|
119
|
+
)
|
|
120
|
+
return str(value) if value else None
|
|
121
|
+
|
|
122
|
+
def _normalize_path(self, raw_path: str) -> str:
|
|
123
|
+
path = raw_path.strip().strip('"').replace("\\", "/")
|
|
124
|
+
if self.project_root:
|
|
125
|
+
try:
|
|
126
|
+
candidate = Path(path)
|
|
127
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (self.project_root / path).resolve()
|
|
128
|
+
return resolved.relative_to(self.project_root).as_posix()
|
|
129
|
+
except (OSError, ValueError):
|
|
130
|
+
pass
|
|
131
|
+
if re.match(r"^[A-Za-z]:/", path):
|
|
132
|
+
parts = PurePosixPath(path).parts
|
|
133
|
+
path = "/".join(parts[1:])
|
|
134
|
+
return path[2:] if path.startswith("./") else path
|
|
135
|
+
|
|
136
|
+
def _is_planned_file(self, path: str, task: Task) -> bool:
|
|
137
|
+
for planned in task.planned_files:
|
|
138
|
+
planned_path = self._normalize_path(planned.path)
|
|
139
|
+
if path == planned_path or fnmatch.fnmatch(path, planned_path):
|
|
140
|
+
return True
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def _matches_any(self, path: str, patterns: tuple[str, ...]) -> bool:
|
|
144
|
+
return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)
|
|
@@ -0,0 +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()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from devcouncil.app.errors import ExecutionError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def resolve_project_path(project_root: Path, path: str) -> Path:
|
|
7
|
+
"""Resolve a repository-relative path and reject paths outside the project."""
|
|
8
|
+
root_resolved = project_root.resolve()
|
|
9
|
+
resolved = (root_resolved / path).resolve()
|
|
10
|
+
try:
|
|
11
|
+
resolved.relative_to(root_resolved)
|
|
12
|
+
except ValueError as exc:
|
|
13
|
+
raise ExecutionError(f"Path traversal blocked: {path} resolves outside project root.") from exc
|
|
14
|
+
return resolved
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Literal, Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
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
|
+
|
|
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
|
+
|
|
32
|
+
def is_file_change_allowed(
|
|
33
|
+
self,
|
|
34
|
+
path: str,
|
|
35
|
+
task: Task,
|
|
36
|
+
operation: Literal["create", "modify", "delete", "write"] = "write",
|
|
37
|
+
) -> bool:
|
|
38
|
+
"""Check if a file change is authorized by the task or policy."""
|
|
39
|
+
# 1. Check restricted paths (e.g. .git) and dynamic ignores
|
|
40
|
+
all_restricted = self.policy.restricted_paths + self.dynamic_ignores
|
|
41
|
+
for restricted in all_restricted:
|
|
42
|
+
if fnmatch.fnmatch(path, restricted) or path.startswith(restricted.strip("*")):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
# 2. Check if path is in task's planned files with a compatible operation.
|
|
46
|
+
planned = self._planned_file_for(path, task)
|
|
47
|
+
if not planned:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
if planned.allowed_change == "read_only":
|
|
51
|
+
return False
|
|
52
|
+
if operation == "write":
|
|
53
|
+
return planned.allowed_change in {"create", "modify"}
|
|
54
|
+
return planned.allowed_change == operation
|
|
55
|
+
|
|
56
|
+
def _planned_file_for(self, path: str, task: Task) -> Optional[PlannedFile]:
|
|
57
|
+
normalized = path.replace("\\", "/")
|
|
58
|
+
for planned in task.planned_files:
|
|
59
|
+
planned_path = planned.path.replace("\\", "/")
|
|
60
|
+
if normalized == planned_path or fnmatch.fnmatch(normalized, planned_path):
|
|
61
|
+
return planned
|
|
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
|
+
|
|
76
|
+
def validate_action(
|
|
77
|
+
self,
|
|
78
|
+
action_type: str,
|
|
79
|
+
target: str,
|
|
80
|
+
task: Task,
|
|
81
|
+
operation: Literal["create", "modify", "delete", "write"] = "write",
|
|
82
|
+
):
|
|
83
|
+
"""Raise GatingError if an execution action violates permissions."""
|
|
84
|
+
if action_type == "file_write":
|
|
85
|
+
if not self.is_file_change_allowed(target, task, operation):
|
|
86
|
+
raise GatingError(
|
|
87
|
+
f"Unauthorized file {operation}: {target}. "
|
|
88
|
+
"File and operation must match task planned_files."
|
|
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.")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from devcouncil.domain.requirement import Requirement
|
|
5
|
+
from devcouncil.domain.task import Task
|
|
6
|
+
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
7
|
+
|
|
8
|
+
class PromptBuilder:
|
|
9
|
+
def __init__(self, project_root: Path = Path(".")):
|
|
10
|
+
self.project_root = project_root
|
|
11
|
+
|
|
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
|
+
|
|
28
|
+
prompt += "\n## Allowed files\n"
|
|
29
|
+
for pf in task.planned_files:
|
|
30
|
+
prompt += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
|
|
31
|
+
|
|
32
|
+
if task.forbidden_changes:
|
|
33
|
+
prompt += "\n## Forbidden changes\n"
|
|
34
|
+
for fc in task.forbidden_changes:
|
|
35
|
+
prompt += f"- `{fc}`\n"
|
|
36
|
+
|
|
37
|
+
prompt += "\n## Expected tests\n"
|
|
38
|
+
for et in task.expected_tests:
|
|
39
|
+
prompt += f"- `{et}`\n"
|
|
40
|
+
|
|
41
|
+
prompt += "\n## Allowed commands\n"
|
|
42
|
+
for cmd in task.allowed_commands:
|
|
43
|
+
prompt += f"- `{cmd}`\n"
|
|
44
|
+
|
|
45
|
+
graph_context = CodeReviewGraphAdapter(self.project_root).prompt_section(
|
|
46
|
+
[planned.path for planned in task.planned_files]
|
|
47
|
+
)
|
|
48
|
+
if graph_context:
|
|
49
|
+
prompt += f"\n{graph_context}"
|
|
50
|
+
|
|
51
|
+
prompt += """
|
|
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
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import subprocess
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
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
|
|
11
|
+
from devcouncil.app.errors import ExecutionError
|
|
12
|
+
|
|
13
|
+
from devcouncil.execution.patch import PatchEngine
|
|
14
|
+
from devcouncil.execution.paths import resolve_project_path
|
|
15
|
+
from devcouncil.telemetry.traces import TraceLogger
|
|
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
|
+
|
|
28
|
+
def _validate_path_within_root(self, path: str) -> None:
|
|
29
|
+
"""Ensure a path resolves to a location within the project root."""
|
|
30
|
+
resolve_project_path(self.project_root, path)
|
|
31
|
+
|
|
32
|
+
def apply_patch(self, patch: str, task: Task) -> bool:
|
|
33
|
+
"""Apply a patch if permissions allow (all affected files must be in planned_files)."""
|
|
34
|
+
changes = self._extract_patch_changes(patch)
|
|
35
|
+
for path, operation in changes.items():
|
|
36
|
+
self._validate_path_within_root(path)
|
|
37
|
+
self.permissions.validate_action("file_write", path, task, operation=operation)
|
|
38
|
+
applied = self.patch_engine.apply_patch(patch)
|
|
39
|
+
TraceLogger(self.project_root).log_event(
|
|
40
|
+
"tool_patch_applied",
|
|
41
|
+
{"paths": sorted(changes), "success": applied},
|
|
42
|
+
task_id=task.id,
|
|
43
|
+
summary=f"Patch {'applied' if applied else 'failed'} for {task.id}",
|
|
44
|
+
)
|
|
45
|
+
return applied
|
|
46
|
+
|
|
47
|
+
def _extract_patch_paths(self, patch: str) -> set[str]:
|
|
48
|
+
return set(self._extract_patch_changes(patch))
|
|
49
|
+
|
|
50
|
+
def _extract_patch_changes(self, patch: str) -> dict[str, Literal["create", "modify", "delete"]]:
|
|
51
|
+
"""Extract repository-relative file paths touched by a unified git patch."""
|
|
52
|
+
changes: dict[str, Literal["create", "modify", "delete"]] = {}
|
|
53
|
+
old_path: Optional[str] = None
|
|
54
|
+
old_is_null = False
|
|
55
|
+
for line in patch.splitlines():
|
|
56
|
+
if line.startswith("diff --git "):
|
|
57
|
+
parts = line.split()
|
|
58
|
+
cleaned = self._normalize_patch_path(parts[3]) if len(parts) > 3 else None
|
|
59
|
+
if cleaned:
|
|
60
|
+
changes.setdefault(cleaned, "modify")
|
|
61
|
+
old_path = self._normalize_patch_path(parts[2]) if len(parts) > 2 else None
|
|
62
|
+
old_is_null = False
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if line.startswith("--- "):
|
|
66
|
+
raw = line[4:].split("\t", 1)[0].strip()
|
|
67
|
+
old_is_null = raw == "/dev/null"
|
|
68
|
+
old_path = self._normalize_patch_path(raw)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
if line.startswith("+++ "):
|
|
72
|
+
raw = line[4:].split("\t", 1)[0].strip()
|
|
73
|
+
if raw == "/dev/null":
|
|
74
|
+
if old_path:
|
|
75
|
+
changes[old_path] = "delete"
|
|
76
|
+
continue
|
|
77
|
+
new_path = self._normalize_patch_path(raw)
|
|
78
|
+
if new_path:
|
|
79
|
+
changes[new_path] = "create" if old_is_null else "modify"
|
|
80
|
+
|
|
81
|
+
if not changes:
|
|
82
|
+
raise ExecutionError("Patch does not declare any affected files.")
|
|
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]
|
|
97
|
+
filename = f"{task_id}-{cmd_hash}-{stream}.log"
|
|
98
|
+
log_path = log_dir / filename
|
|
99
|
+
log_path.write_text(redact_string(content), encoding="utf-8")
|
|
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
|
+
)
|
|
126
|
+
stdout = result.stdout or ""
|
|
127
|
+
stderr = result.stderr or ""
|
|
128
|
+
stdout_path = self._save_command_log(task.id, command, "stdout", stdout)
|
|
129
|
+
stderr_path = self._save_command_log(task.id, command, "stderr", stderr)
|
|
130
|
+
stdout_summary = redact_string(stdout[-500:])
|
|
131
|
+
stderr_summary = redact_string(stderr[-500:])
|
|
132
|
+
TraceLogger(self.project_root).log_event(
|
|
133
|
+
"command_executed",
|
|
134
|
+
{"command": command, "exit_code": result.returncode},
|
|
135
|
+
task_id=task.id,
|
|
136
|
+
summary=f"{command} exited {result.returncode}",
|
|
137
|
+
)
|
|
138
|
+
return CommandResult(
|
|
139
|
+
command=command,
|
|
140
|
+
exit_code=result.returncode,
|
|
141
|
+
stdout_path=stdout_path,
|
|
142
|
+
stderr_path=stderr_path,
|
|
143
|
+
summary=f"Exit code {result.returncode}. stdout: {stdout_summary}. stderr: {stderr_summary}"
|
|
144
|
+
)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
raise ExecutionError(f"Command execution failed: {e}")
|
|
147
|
+
|
|
148
|
+
def write_file(self, path: str, content: str, task: Task):
|
|
149
|
+
"""Write content to a file if allowed by permissions."""
|
|
150
|
+
self._validate_path_within_root(path)
|
|
151
|
+
full_path = resolve_project_path(self.project_root, path)
|
|
152
|
+
operation = "modify" if full_path.exists() else "create"
|
|
153
|
+
self.permissions.validate_action("file_write", path, task, operation=operation)
|
|
154
|
+
|
|
155
|
+
logger.info(f"Writing authorized file: {path}")
|
|
156
|
+
try:
|
|
157
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
full_path.write_text(content, encoding="utf-8")
|
|
159
|
+
TraceLogger(self.project_root).log_event(
|
|
160
|
+
"file_written",
|
|
161
|
+
{"path": path},
|
|
162
|
+
task_id=task.id,
|
|
163
|
+
summary=f"Wrote {path}",
|
|
164
|
+
)
|
|
165
|
+
except Exception as e:
|
|
166
|
+
raise ExecutionError(f"Failed to write file {path}: {e}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from devcouncil.domain.task import Task
|
|
6
|
+
from devcouncil.domain.requirement import Requirement
|
|
7
|
+
from devcouncil.execution.executor import Executor, ExecutionResult
|
|
8
|
+
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
class MiniSWEExecutor(Executor):
|
|
13
|
+
def __init__(self, project_root: Path):
|
|
14
|
+
self.project_root = project_root
|
|
15
|
+
|
|
16
|
+
def run_task(self, task: Task, requirements: list[Requirement]) -> ExecutionResult:
|
|
17
|
+
builder = PromptBuilder(self.project_root)
|
|
18
|
+
task_prompt = builder.build_task_prompt(task, requirements)
|
|
19
|
+
|
|
20
|
+
# Write temporary instruction file for mini-SWE-agent
|
|
21
|
+
instruction_file = self.project_root / ".devcouncil" / f"{task.id}-mini-swe-task.md"
|
|
22
|
+
instruction_file.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
instruction_file.write_text(task_prompt, encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
console.print(f"Starting [bold]mini-SWE-agent[/bold] for task {task.id}...")
|
|
26
|
+
|
|
27
|
+
# In a real implementation, we'd invoke the agent CLI
|
|
28
|
+
# For now, we simulate the command call
|
|
29
|
+
cmd = [
|
|
30
|
+
sys.executable, "-m", "mini_swe_agent.main",
|
|
31
|
+
"--instruction-file", str(instruction_file),
|
|
32
|
+
"--repo-path", str(self.project_root)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
console.print(f"Command: [dim]{' '.join(cmd)}[/dim]")
|
|
36
|
+
|
|
37
|
+
# Since I might not have mini_swe_agent installed here,
|
|
38
|
+
# I'll just explain what it would do.
|
|
39
|
+
console.print("[yellow]Note: mini_swe_agent must be installed in the environment.[/yellow]")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
cmd,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
errors="replace",
|
|
48
|
+
cwd=self.project_root,
|
|
49
|
+
timeout=1800,
|
|
50
|
+
)
|
|
51
|
+
self._write_log(task.id, result)
|
|
52
|
+
if result.returncode != 0:
|
|
53
|
+
console.print(f"[red]mini-SWE-agent exited with {result.returncode}.[/red]")
|
|
54
|
+
return ExecutionResult(success=False, message='Execution failed')
|
|
55
|
+
return ExecutionResult(success=True, message='Execution successful')
|
|
56
|
+
except Exception as e:
|
|
57
|
+
console.print(f"[red]Error running mini-SWE-agent: {e}[/red]")
|
|
58
|
+
return ExecutionResult(success=False, message='Execution failed')
|
|
59
|
+
|
|
60
|
+
def _write_log(self, task_id: str, result: subprocess.CompletedProcess[str]) -> None:
|
|
61
|
+
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
62
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
log_path = log_dir / f"{task_id}-mini-swe.log"
|
|
64
|
+
log_path.write_text(
|
|
65
|
+
"\n".join([
|
|
66
|
+
f"command_returncode={result.returncode}",
|
|
67
|
+
"=== stdout ===",
|
|
68
|
+
result.stdout or "",
|
|
69
|
+
"=== stderr ===",
|
|
70
|
+
result.stderr or "",
|
|
71
|
+
]),
|
|
72
|
+
encoding="utf-8",
|
|
73
|
+
)
|
|
File without changes
|