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.
Files changed (128) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +62 -543
  3. package/package.json +1 -1
  4. package/pyproject.toml +29 -26
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +135 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +143 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/cli/commands/artifacts.py +51 -48
  22. package/src/devcouncil/cli/commands/ast.py +22 -0
  23. package/src/devcouncil/cli/commands/baseline.py +35 -32
  24. package/src/devcouncil/cli/commands/config.py +76 -54
  25. package/src/devcouncil/cli/commands/dashboard.py +26 -0
  26. package/src/devcouncil/cli/commands/doctor.py +86 -42
  27. package/src/devcouncil/cli/commands/go.py +237 -0
  28. package/src/devcouncil/cli/commands/hook.py +96 -29
  29. package/src/devcouncil/cli/commands/init.py +67 -56
  30. package/src/devcouncil/cli/commands/integrate.py +320 -14
  31. package/src/devcouncil/cli/commands/lsp.py +20 -0
  32. package/src/devcouncil/cli/commands/map.py +25 -21
  33. package/src/devcouncil/cli/commands/plan.py +257 -206
  34. package/src/devcouncil/cli/commands/prompt.py +36 -33
  35. package/src/devcouncil/cli/commands/repair.py +72 -69
  36. package/src/devcouncil/cli/commands/report.py +112 -54
  37. package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
  38. package/src/devcouncil/cli/commands/rollback.py +49 -47
  39. package/src/devcouncil/cli/commands/run.py +252 -207
  40. package/src/devcouncil/cli/commands/setup.py +159 -18
  41. package/src/devcouncil/cli/commands/show.py +76 -57
  42. package/src/devcouncil/cli/commands/status.py +117 -105
  43. package/src/devcouncil/cli/commands/tasks.py +55 -41
  44. package/src/devcouncil/cli/commands/trace.py +2 -1
  45. package/src/devcouncil/cli/commands/verify.py +158 -128
  46. package/src/devcouncil/cli/commands/version.py +20 -20
  47. package/src/devcouncil/cli/commands/watch.py +574 -0
  48. package/src/devcouncil/cli/main.py +42 -24
  49. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  50. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  51. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  52. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  53. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  54. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  55. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  56. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  57. package/src/devcouncil/domain/assumption.py +17 -17
  58. package/src/devcouncil/domain/critique.py +32 -32
  59. package/src/devcouncil/domain/evidence.py +27 -27
  60. package/src/devcouncil/domain/gap.py +26 -26
  61. package/src/devcouncil/domain/requirement.py +22 -22
  62. package/src/devcouncil/domain/task.py +26 -26
  63. package/src/devcouncil/execution/__init__.py +1 -1
  64. package/src/devcouncil/execution/context_builder.py +54 -54
  65. package/src/devcouncil/execution/executor.py +15 -15
  66. package/src/devcouncil/execution/hook_policy.py +24 -3
  67. package/src/devcouncil/execution/patch.py +28 -28
  68. package/src/devcouncil/execution/permissions.py +44 -44
  69. package/src/devcouncil/execution/prompt_builder.py +23 -23
  70. package/src/devcouncil/execution/task_runner.py +63 -63
  71. package/src/devcouncil/executors/__init__.py +1 -1
  72. package/src/devcouncil/executors/coding_cli.py +112 -0
  73. package/src/devcouncil/executors/mini_swe.py +63 -63
  74. package/src/devcouncil/executors/native/agent.py +81 -81
  75. package/src/devcouncil/executors/openhands.py +56 -56
  76. package/src/devcouncil/gating/__init__.py +1 -1
  77. package/src/devcouncil/gating/checks/clean_git.py +50 -45
  78. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  79. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  80. package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
  81. package/src/devcouncil/gating/policy.py +157 -157
  82. package/src/devcouncil/indexing/__init__.py +1 -1
  83. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  84. package/src/devcouncil/indexing/graph_index.py +48 -48
  85. package/src/devcouncil/indexing/lsp.py +120 -0
  86. package/src/devcouncil/indexing/repo_mapper.py +208 -204
  87. package/src/devcouncil/integrations/github.py +35 -35
  88. package/src/devcouncil/integrations/gitnexus.py +27 -27
  89. package/src/devcouncil/integrations/graphify.py +34 -34
  90. package/src/devcouncil/integrations/mcp/server.py +549 -96
  91. package/src/devcouncil/integrations/pr_comments.py +62 -0
  92. package/src/devcouncil/live/__init__.py +2 -0
  93. package/src/devcouncil/live/cards.py +207 -0
  94. package/src/devcouncil/live/models.py +63 -0
  95. package/src/devcouncil/live/repair_prompt.py +83 -0
  96. package/src/devcouncil/live/reviewer.py +70 -0
  97. package/src/devcouncil/live/signals.py +135 -0
  98. package/src/devcouncil/live/summary.py +34 -0
  99. package/src/devcouncil/live/tasks.py +18 -0
  100. package/src/devcouncil/live/transcripts.py +138 -0
  101. package/src/devcouncil/llm/__init__.py +1 -1
  102. package/src/devcouncil/llm/cache.py +38 -38
  103. package/src/devcouncil/llm/provider.py +146 -125
  104. package/src/devcouncil/llm/router.py +111 -111
  105. package/src/devcouncil/planning/__init__.py +1 -1
  106. package/src/devcouncil/planning/arbiter_service.py +57 -57
  107. package/src/devcouncil/planning/critique_service.py +66 -66
  108. package/src/devcouncil/planning/plan_service.py +46 -46
  109. package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
  110. package/src/devcouncil/planning/repair_service.py +39 -39
  111. package/src/devcouncil/planning/spec_service.py +44 -44
  112. package/src/devcouncil/reporting/github_check.py +32 -32
  113. package/src/devcouncil/reporting/json_report.py +20 -17
  114. package/src/devcouncil/reporting/markdown_report.py +68 -46
  115. package/src/devcouncil/reporting/report_builder.py +14 -14
  116. package/src/devcouncil/storage/db.py +66 -66
  117. package/src/devcouncil/storage/models.py +83 -83
  118. package/src/devcouncil/storage/repositories.py +299 -222
  119. package/src/devcouncil/telemetry/cost.py +34 -34
  120. package/src/devcouncil/telemetry/tracker.py +49 -49
  121. package/src/devcouncil/ui/__init__.py +1 -0
  122. package/src/devcouncil/ui/dashboard.py +122 -0
  123. package/src/devcouncil/utils/__init__.py +1 -1
  124. package/src/devcouncil/utils/redaction.py +141 -141
  125. package/src/devcouncil/verification/__init__.py +1 -1
  126. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  127. package/src/devcouncil/verification/verifier.py +319 -302
  128. 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 Claude-style pre-tool-use events."""
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 = {"write_file", "edit_file", "replace", "Write", "Edit", "MultiEdit"}
51
- shell_tools = {"bash", "shell", "run_command", "Bash"}
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
+ )