devcouncil 0.2.0 → 0.2.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 (205) hide show
  1. package/README.md +191 -253
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +6 -17
  4. package/pyproject.toml +0 -66
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -320
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -163
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/assets/__init__.py +0 -1
  23. package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
  24. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  25. package/src/devcouncil/cli/__init__.py +0 -0
  26. package/src/devcouncil/cli/commands/__init__.py +0 -0
  27. package/src/devcouncil/cli/commands/agents.py +0 -292
  28. package/src/devcouncil/cli/commands/artifacts.py +0 -54
  29. package/src/devcouncil/cli/commands/ast.py +0 -22
  30. package/src/devcouncil/cli/commands/baseline.py +0 -35
  31. package/src/devcouncil/cli/commands/check.py +0 -209
  32. package/src/devcouncil/cli/commands/config.py +0 -115
  33. package/src/devcouncil/cli/commands/cost.py +0 -57
  34. package/src/devcouncil/cli/commands/dashboard.py +0 -31
  35. package/src/devcouncil/cli/commands/doctor.py +0 -340
  36. package/src/devcouncil/cli/commands/evidence.py +0 -48
  37. package/src/devcouncil/cli/commands/go.py +0 -656
  38. package/src/devcouncil/cli/commands/handoff.py +0 -69
  39. package/src/devcouncil/cli/commands/hook.py +0 -237
  40. package/src/devcouncil/cli/commands/init.py +0 -289
  41. package/src/devcouncil/cli/commands/integrate.py +0 -1515
  42. package/src/devcouncil/cli/commands/lsp.py +0 -20
  43. package/src/devcouncil/cli/commands/map.py +0 -112
  44. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  45. package/src/devcouncil/cli/commands/plan.py +0 -488
  46. package/src/devcouncil/cli/commands/prompt.py +0 -61
  47. package/src/devcouncil/cli/commands/repair.py +0 -89
  48. package/src/devcouncil/cli/commands/report.py +0 -137
  49. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
  50. package/src/devcouncil/cli/commands/rollback.py +0 -59
  51. package/src/devcouncil/cli/commands/run.py +0 -289
  52. package/src/devcouncil/cli/commands/runs.py +0 -223
  53. package/src/devcouncil/cli/commands/scaffold.py +0 -32
  54. package/src/devcouncil/cli/commands/semantic.py +0 -47
  55. package/src/devcouncil/cli/commands/setup.py +0 -362
  56. package/src/devcouncil/cli/commands/shell.py +0 -73
  57. package/src/devcouncil/cli/commands/show.py +0 -76
  58. package/src/devcouncil/cli/commands/skills.py +0 -88
  59. package/src/devcouncil/cli/commands/status.py +0 -141
  60. package/src/devcouncil/cli/commands/tasks.py +0 -55
  61. package/src/devcouncil/cli/commands/trace.py +0 -88
  62. package/src/devcouncil/cli/commands/verify.py +0 -328
  63. package/src/devcouncil/cli/commands/version.py +0 -20
  64. package/src/devcouncil/cli/commands/watch.py +0 -574
  65. package/src/devcouncil/cli/commands/watch_fs.py +0 -40
  66. package/src/devcouncil/cli/main.py +0 -137
  67. package/src/devcouncil/council/__init__.py +0 -0
  68. package/src/devcouncil/council/prompts/__init__.py +0 -0
  69. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  70. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  71. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  72. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  73. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  74. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  75. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  76. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  77. package/src/devcouncil/domain/__init__.py +0 -0
  78. package/src/devcouncil/domain/assumption.py +0 -17
  79. package/src/devcouncil/domain/critique.py +0 -32
  80. package/src/devcouncil/domain/evidence.py +0 -47
  81. package/src/devcouncil/domain/gap.py +0 -52
  82. package/src/devcouncil/domain/requirement.py +0 -22
  83. package/src/devcouncil/domain/task.py +0 -55
  84. package/src/devcouncil/execution/__init__.py +0 -1
  85. package/src/devcouncil/execution/checkpoints.py +0 -246
  86. package/src/devcouncil/execution/context_builder.py +0 -60
  87. package/src/devcouncil/execution/executor.py +0 -15
  88. package/src/devcouncil/execution/fs_watcher.py +0 -180
  89. package/src/devcouncil/execution/handoff.py +0 -102
  90. package/src/devcouncil/execution/hook_policy.py +0 -253
  91. package/src/devcouncil/execution/patch.py +0 -77
  92. package/src/devcouncil/execution/paths.py +0 -14
  93. package/src/devcouncil/execution/permissions.py +0 -85
  94. package/src/devcouncil/execution/policy_engine.py +0 -343
  95. package/src/devcouncil/execution/prompt_builder.py +0 -671
  96. package/src/devcouncil/execution/shell_session.py +0 -225
  97. package/src/devcouncil/execution/task_runner.py +0 -170
  98. package/src/devcouncil/executors/__init__.py +0 -1
  99. package/src/devcouncil/executors/agent_registry.py +0 -575
  100. package/src/devcouncil/executors/coding_cli.py +0 -736
  101. package/src/devcouncil/executors/mini_swe.py +0 -73
  102. package/src/devcouncil/executors/native/__init__.py +0 -0
  103. package/src/devcouncil/executors/native/agent.py +0 -208
  104. package/src/devcouncil/executors/openhands.py +0 -71
  105. package/src/devcouncil/gating/__init__.py +0 -1
  106. package/src/devcouncil/gating/checks/__init__.py +0 -0
  107. package/src/devcouncil/gating/checks/clean_git.py +0 -52
  108. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  109. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  110. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
  111. package/src/devcouncil/gating/policy.py +0 -338
  112. package/src/devcouncil/hardware.py +0 -184
  113. package/src/devcouncil/indexing/__init__.py +0 -1
  114. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  115. package/src/devcouncil/indexing/graph_index.py +0 -48
  116. package/src/devcouncil/indexing/lsp.py +0 -161
  117. package/src/devcouncil/indexing/repo_mapper.py +0 -1455
  118. package/src/devcouncil/indexing/semantic_index.py +0 -205
  119. package/src/devcouncil/integrations/actions.py +0 -146
  120. package/src/devcouncil/integrations/check.py +0 -423
  121. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  122. package/src/devcouncil/integrations/github.py +0 -39
  123. package/src/devcouncil/integrations/github_intent.py +0 -142
  124. package/src/devcouncil/integrations/gitnexus.py +0 -62
  125. package/src/devcouncil/integrations/graphify.py +0 -34
  126. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  127. package/src/devcouncil/integrations/mcp/server.py +0 -2122
  128. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
  129. package/src/devcouncil/integrations/pr_comments.py +0 -62
  130. package/src/devcouncil/live/__init__.py +0 -2
  131. package/src/devcouncil/live/cards.py +0 -349
  132. package/src/devcouncil/live/models.py +0 -63
  133. package/src/devcouncil/live/repair_prompt.py +0 -83
  134. package/src/devcouncil/live/reviewer.py +0 -70
  135. package/src/devcouncil/live/signals.py +0 -135
  136. package/src/devcouncil/live/summary.py +0 -34
  137. package/src/devcouncil/live/tasks.py +0 -18
  138. package/src/devcouncil/live/transcripts.py +0 -141
  139. package/src/devcouncil/llm/__init__.py +0 -1
  140. package/src/devcouncil/llm/cache.py +0 -42
  141. package/src/devcouncil/llm/model_defaults.yaml +0 -44
  142. package/src/devcouncil/llm/provider.py +0 -627
  143. package/src/devcouncil/llm/router.py +0 -310
  144. package/src/devcouncil/optimization/__init__.py +0 -1
  145. package/src/devcouncil/optimization/gepa_agent.py +0 -318
  146. package/src/devcouncil/planning/__init__.py +0 -1
  147. package/src/devcouncil/planning/arbiter_service.py +0 -57
  148. package/src/devcouncil/planning/correction_manifest.py +0 -303
  149. package/src/devcouncil/planning/critique_service.py +0 -71
  150. package/src/devcouncil/planning/plan_service.py +0 -60
  151. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
  152. package/src/devcouncil/planning/repair_service.py +0 -39
  153. package/src/devcouncil/planning/spec_service.py +0 -70
  154. package/src/devcouncil/repo/__init__.py +0 -0
  155. package/src/devcouncil/repo/ci_scaffold.py +0 -157
  156. package/src/devcouncil/repo/gitignore.py +0 -123
  157. package/src/devcouncil/repo/sca.py +0 -374
  158. package/src/devcouncil/reporting/__init__.py +0 -0
  159. package/src/devcouncil/reporting/github_check.py +0 -32
  160. package/src/devcouncil/reporting/json_report.py +0 -30
  161. package/src/devcouncil/reporting/markdown_report.py +0 -83
  162. package/src/devcouncil/reporting/report_builder.py +0 -14
  163. package/src/devcouncil/skills/__init__.py +0 -19
  164. package/src/devcouncil/skills/library/README.md +0 -46
  165. package/src/devcouncil/skills/library/ai-training.md +0 -50
  166. package/src/devcouncil/skills/library/android.md +0 -50
  167. package/src/devcouncil/skills/library/backend.md +0 -52
  168. package/src/devcouncil/skills/library/core-engineering.md +0 -95
  169. package/src/devcouncil/skills/library/data-engineering.md +0 -47
  170. package/src/devcouncil/skills/library/desktop.md +0 -46
  171. package/src/devcouncil/skills/library/devops.md +0 -48
  172. package/src/devcouncil/skills/library/game-dev.md +0 -46
  173. package/src/devcouncil/skills/library/ios.md +0 -48
  174. package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
  175. package/src/devcouncil/skills/library/security.md +0 -48
  176. package/src/devcouncil/skills/library/systems.md +0 -48
  177. package/src/devcouncil/skills/library/web.md +0 -47
  178. package/src/devcouncil/skills/library/windows.md +0 -47
  179. package/src/devcouncil/skills/registry.py +0 -330
  180. package/src/devcouncil/storage/__init__.py +0 -0
  181. package/src/devcouncil/storage/db.py +0 -147
  182. package/src/devcouncil/storage/models.py +0 -204
  183. package/src/devcouncil/storage/native.py +0 -557
  184. package/src/devcouncil/storage/repositories.py +0 -485
  185. package/src/devcouncil/telemetry/__init__.py +0 -0
  186. package/src/devcouncil/telemetry/cost.py +0 -140
  187. package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
  188. package/src/devcouncil/telemetry/pricing.py +0 -28
  189. package/src/devcouncil/telemetry/traces.py +0 -146
  190. package/src/devcouncil/telemetry/tracker.py +0 -52
  191. package/src/devcouncil/ui/__init__.py +0 -1
  192. package/src/devcouncil/ui/dashboard.py +0 -423
  193. package/src/devcouncil/utils/__init__.py +0 -1
  194. package/src/devcouncil/utils/redaction.py +0 -147
  195. package/src/devcouncil/utils/subprocess_env.py +0 -69
  196. package/src/devcouncil/verification/__init__.py +0 -1
  197. package/src/devcouncil/verification/acceptance_compiler.py +0 -125
  198. package/src/devcouncil/verification/ad_hoc_check.py +0 -129
  199. package/src/devcouncil/verification/diff_coverage.py +0 -353
  200. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  201. package/src/devcouncil/verification/next_actions.py +0 -189
  202. package/src/devcouncil/verification/sandbox.py +0 -178
  203. package/src/devcouncil/verification/test_resolver.py +0 -91
  204. package/src/devcouncil/verification/verifier.py +0 -1548
  205. package/uv.lock +0 -1226
@@ -1,225 +0,0 @@
1
- """Guarded shell command session for DevCouncil tasks."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import shlex
7
- import shutil
8
- import subprocess
9
- import uuid
10
- from pathlib import Path
11
-
12
- from rich.console import Console
13
-
14
- from devcouncil.domain.evidence import CommandResult
15
- from devcouncil.domain.task import Task
16
- from devcouncil.execution.checkpoints import CheckpointService
17
- from devcouncil.execution.hook_policy import HookPolicy
18
- from devcouncil.storage.db import get_db
19
- from devcouncil.storage.native import ShellCommandRepository, ShellSessionRepository, TaskLeaseRepository
20
- from devcouncil.storage.repositories import EvidenceRepository, TaskRepository
21
- from devcouncil.telemetry.traces import TraceLogger
22
-
23
-
24
- class ShellBackend:
25
- def run_command(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
26
- raise NotImplementedError
27
-
28
-
29
- class CommandLoopBackend(ShellBackend):
30
- def run_command(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
31
- args = shlex.split(command, posix=(os.name != "nt"))
32
- return subprocess.run(
33
- args,
34
- cwd=cwd,
35
- capture_output=True,
36
- text=True,
37
- encoding="utf-8",
38
- errors="replace",
39
- env=env,
40
- )
41
-
42
-
43
- class ShellWrappedBackend(ShellBackend):
44
- """Runs each command through an explicit shell (pwsh/bash/zsh/...)."""
45
-
46
- _LAUNCHERS = {
47
- "pwsh": ["pwsh", "-NoProfile", "-Command"],
48
- "powershell": ["powershell", "-NoProfile", "-Command"],
49
- "bash": ["bash", "-lc"],
50
- "zsh": ["zsh", "-lc"],
51
- "sh": ["sh", "-lc"],
52
- }
53
-
54
- def __init__(self, shell: str):
55
- launcher = self._LAUNCHERS.get(shell)
56
- if launcher is None:
57
- supported = ", ".join(sorted(self._LAUNCHERS))
58
- raise ValueError(f"Unknown shell backend '{shell}'. Use auto or one of: {supported}.")
59
- if not shutil.which(launcher[0]):
60
- raise ValueError(f"Shell '{launcher[0]}' is not installed or not on PATH.")
61
- self.launcher = launcher
62
-
63
- def run_command(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
64
- return subprocess.run(
65
- [*self.launcher, command],
66
- cwd=cwd,
67
- capture_output=True,
68
- text=True,
69
- encoding="utf-8",
70
- errors="replace",
71
- env=env,
72
- )
73
-
74
-
75
- _EVIDENCE_COMMAND_HINTS = ("pytest", "ruff", "mypy", "npm test", "npm run lint", "npm run typecheck")
76
-
77
- console = Console()
78
-
79
-
80
- class GuardedShellSession:
81
- def __init__(self, project_root: Path, task: Task, *, shell: str = "auto"):
82
- self.project_root = project_root.resolve()
83
- self.task = task
84
- self.shell = shell
85
- # HookPolicy (not the bare TaskPolicyEngine) so a chained command is split into
86
- # its segments and EACH is allowlisted, plus git-safety denies (force-push,
87
- # --no-verify, protected-branch reset). Critical for the `--shell bash/zsh`
88
- # backend, which hands the whole string to a real shell that interprets
89
- # `;`/`&&`/`|` — a single whole-string allowlist match would let an allowed
90
- # prefix smuggle an arbitrary command past the gate.
91
- self.policy = HookPolicy(self.project_root)
92
- self.backend: ShellBackend = (
93
- CommandLoopBackend() if shell in {"auto", "loop"} else ShellWrappedBackend(shell)
94
- )
95
- self.lease_token: str | None = None
96
- self.session_id: str | None = None
97
- self.log_dir = self.project_root / ".devcouncil" / "logs"
98
- self.log_dir.mkdir(parents=True, exist_ok=True)
99
-
100
- def start(self, *, force: bool = False) -> None:
101
- db = get_db(self.project_root)
102
- if not db:
103
- raise RuntimeError("DevCouncil not initialized.")
104
- with db.get_session() as session:
105
- lease = TaskLeaseRepository(session).acquire(
106
- self.task.id,
107
- owner="dev shell",
108
- agent=self.shell,
109
- force=force,
110
- )
111
- self.lease_token = lease.lease_token
112
- shell_session = ShellSessionRepository(session).start(
113
- self.task.id,
114
- self.shell,
115
- str(self.project_root),
116
- lease_id=lease.id,
117
- )
118
- self.session_id = shell_session.id
119
- self.task.status = "running"
120
- TaskRepository(session).save(self.task)
121
- CheckpointService(self.project_root).create_before(self.task.id)
122
-
123
- def finish(self) -> None:
124
- db = get_db(self.project_root)
125
- if not db or not self.lease_token:
126
- return
127
- with db.get_session() as session:
128
- if self.session_id:
129
- ShellSessionRepository(session).finish(self.session_id, "finished")
130
- TaskLeaseRepository(session).release(self.task.id, self.lease_token)
131
-
132
- def run_one(self, command: str) -> int:
133
- normalized = " ".join(command.split())
134
- decision = self.policy.evaluate_command(normalized, self.task)
135
- log_id = uuid.uuid4().hex[:8]
136
- stdout_path = self.log_dir / f"{self.task.id}-{log_id}.stdout.log"
137
- stderr_path = self.log_dir / f"{self.task.id}-{log_id}.stderr.log"
138
-
139
- if decision.action == "deny":
140
- self._record_command(normalized, "denied", reason=decision.reason)
141
- TraceLogger(self.project_root).log_event(
142
- "shell_command_denied",
143
- {"command": normalized, "reason": decision.reason},
144
- task_id=self.task.id,
145
- )
146
- # Tell the user *why* — a silent non-zero exit is unactionable.
147
- console.print(
148
- f"[red]Command denied for {self.task.id}:[/red] {decision.reason or 'not permitted by task policy.'}"
149
- )
150
- console.print(
151
- "[dim]Add it to the task's allowed_commands, or run it outside DevCouncil.[/dim]"
152
- )
153
- return 1
154
-
155
- try:
156
- result = self.backend.run_command(normalized, self.project_root)
157
- except (NotImplementedError, FileNotFoundError, OSError) as exc:
158
- self._record_command(normalized, "denied", reason=str(exc))
159
- console.print(f"[red]Could not run '{normalized}':[/red] {exc}")
160
- return 1
161
-
162
- stdout_path.write_text(result.stdout or "", encoding="utf-8")
163
- stderr_path.write_text(result.stderr or "", encoding="utf-8")
164
- # Echo the command output so the guarded shell is actually usable.
165
- if result.stdout:
166
- console.print(result.stdout, end="", markup=False, highlight=False)
167
- if result.stderr:
168
- console.print(result.stderr, end="", markup=False, highlight=False, style="dim")
169
- status = "finished" if result.returncode == 0 else "failed"
170
- self._record_command(
171
- normalized,
172
- status,
173
- exit_code=result.returncode,
174
- stdout_path=str(stdout_path),
175
- stderr_path=str(stderr_path),
176
- )
177
- if any(hint in normalized for hint in _EVIDENCE_COMMAND_HINTS):
178
- self._save_command_evidence(normalized, result.returncode, result.stdout or result.stderr or "")
179
- TraceLogger(self.project_root).log_event(
180
- "shell_command_finished",
181
- {"command": normalized, "exit_code": result.returncode},
182
- task_id=self.task.id,
183
- )
184
- return result.returncode
185
-
186
- def _record_command(
187
- self,
188
- command: str,
189
- status: str,
190
- *,
191
- exit_code: int | None = None,
192
- reason: str = "",
193
- stdout_path: str = "",
194
- stderr_path: str = "",
195
- ) -> None:
196
- db = get_db(self.project_root)
197
- if not db:
198
- return
199
- with db.get_session() as session:
200
- ShellCommandRepository(session).record(
201
- self.task.id,
202
- command,
203
- status,
204
- session_id=self.session_id,
205
- exit_code=exit_code,
206
- reason=reason,
207
- stdout_path=stdout_path,
208
- stderr_path=stderr_path,
209
- )
210
-
211
- def _save_command_evidence(self, command: str, exit_code: int, summary: str) -> None:
212
- db = get_db(self.project_root)
213
- if not db:
214
- return
215
- with db.get_session() as session:
216
- EvidenceRepository(session).save_command_result(
217
- self.task.id,
218
- CommandResult(
219
- command=command,
220
- exit_code=exit_code,
221
- stdout_path="",
222
- stderr_path="",
223
- summary=summary[:500],
224
- ),
225
- )
@@ -1,170 +0,0 @@
1
- import hashlib
2
- import os
3
- import subprocess
4
- import logging
5
- import re
6
- import shlex
7
- from pathlib import Path
8
- from typing import Literal, Optional
9
- from devcouncil.domain.task import Task
10
- from devcouncil.execution.permissions import PermissionManager
11
- from devcouncil.domain.evidence import CommandResult
12
- from devcouncil.app.errors import ExecutionError
13
-
14
- from devcouncil.execution.patch import PatchEngine
15
- from devcouncil.execution.paths import resolve_project_path
16
- from devcouncil.telemetry.traces import TraceLogger
17
- from devcouncil.utils.redaction import redact_string
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
- class TaskRunner:
22
- """Safely executes task actions while enforcing permission boundaries."""
23
-
24
- def __init__(self, project_root: Path, permission_manager: PermissionManager):
25
- self.project_root = project_root
26
- self.permissions = permission_manager
27
- self.patch_engine = PatchEngine(project_root)
28
-
29
- def _validate_path_within_root(self, path: str) -> None:
30
- """Ensure a path resolves to a location within the project root."""
31
- resolve_project_path(self.project_root, path)
32
-
33
- def apply_patch(self, patch: str, task: Task) -> bool:
34
- """Apply a patch if permissions allow (all affected files must be in planned_files)."""
35
- changes = self._extract_patch_changes(patch)
36
- for path, operation in changes.items():
37
- self._validate_path_within_root(path)
38
- self.permissions.validate_action("file_write", path, task, operation=operation)
39
- applied = self.patch_engine.apply_patch(patch)
40
- TraceLogger(self.project_root).log_event(
41
- "tool_patch_applied",
42
- {"paths": sorted(changes), "success": applied},
43
- task_id=task.id,
44
- summary=f"Patch {'applied' if applied else 'failed'} for {task.id}",
45
- )
46
- return applied
47
-
48
- def _extract_patch_paths(self, patch: str) -> set[str]:
49
- return set(self._extract_patch_changes(patch))
50
-
51
- def _extract_patch_changes(self, patch: str) -> dict[str, Literal["create", "modify", "delete"]]:
52
- """Extract repository-relative file paths touched by a unified git patch."""
53
- changes: dict[str, Literal["create", "modify", "delete"]] = {}
54
- old_path: Optional[str] = None
55
- old_is_null = False
56
- for line in patch.splitlines():
57
- if line.startswith("diff --git "):
58
- parts = line.split()
59
- cleaned = self._normalize_patch_path(parts[3]) if len(parts) > 3 else None
60
- if cleaned:
61
- changes.setdefault(cleaned, "modify")
62
- old_path = self._normalize_patch_path(parts[2]) if len(parts) > 2 else None
63
- old_is_null = False
64
- continue
65
-
66
- if line.startswith("--- "):
67
- raw = line[4:].split("\t", 1)[0].strip()
68
- old_is_null = raw == "/dev/null"
69
- old_path = self._normalize_patch_path(raw)
70
- continue
71
-
72
- if line.startswith("+++ "):
73
- raw = line[4:].split("\t", 1)[0].strip()
74
- if raw == "/dev/null":
75
- if old_path:
76
- changes[old_path] = "delete"
77
- continue
78
- new_path = self._normalize_patch_path(raw)
79
- if new_path:
80
- changes[new_path] = "create" if old_is_null else "modify"
81
-
82
- if not changes:
83
- raise ExecutionError("Patch does not declare any affected files.")
84
- return changes
85
-
86
- def _normalize_patch_path(self, raw_path: str) -> Optional[str]:
87
- raw_path = raw_path.strip().strip('"')
88
- if raw_path == "/dev/null":
89
- return None
90
- raw_path = re.sub(r"^[ab]/", "", raw_path)
91
- return raw_path.replace("\\", "/")
92
-
93
- def _save_command_log(self, task_id: str, command: str, stream: str, content: str) -> str:
94
- """Save command output to a log file and return the path."""
95
- log_dir = self.project_root / ".devcouncil" / "logs"
96
- log_dir.mkdir(parents=True, exist_ok=True)
97
- cmd_hash = hashlib.sha256(command.encode()).hexdigest()[:8]
98
- filename = f"{task_id}-{cmd_hash}-{stream}.log"
99
- log_path = log_dir / filename
100
- log_path.write_text(redact_string(content), encoding="utf-8")
101
- return str(log_path)
102
-
103
- def run_command(self, command: str, task: Task) -> CommandResult:
104
- """Execute a shell command if allowed by permissions."""
105
- self.permissions.validate_action("shell", command, task)
106
-
107
- logger.info(f"Executing authorized command: {command}")
108
-
109
- try:
110
- from devcouncil.app.config import load_config
111
- config = load_config(self.project_root)
112
- timeout = config.execution.command_timeout
113
- except Exception:
114
- timeout = 300
115
-
116
- try:
117
- result = subprocess.run(
118
- # POSIX-correct tokenization on POSIX hosts (so quoted args like
119
- # `pytest -k "a and b"` split right and match the allowlist), Windows
120
- # rules on Windows. Matches shell_session.py.
121
- shlex.split(command, posix=(os.name != "nt")),
122
- shell=False,
123
- capture_output=True,
124
- text=True,
125
- encoding="utf-8",
126
- errors="replace",
127
- cwd=self.project_root,
128
- timeout=timeout
129
- )
130
- stdout = result.stdout or ""
131
- stderr = result.stderr or ""
132
- stdout_path = self._save_command_log(task.id, command, "stdout", stdout)
133
- stderr_path = self._save_command_log(task.id, command, "stderr", stderr)
134
- stdout_summary = redact_string(stdout[-500:])
135
- stderr_summary = redact_string(stderr[-500:])
136
- TraceLogger(self.project_root).log_event(
137
- "command_executed",
138
- {"command": command, "exit_code": result.returncode},
139
- task_id=task.id,
140
- summary=f"{command} exited {result.returncode}",
141
- )
142
- return CommandResult(
143
- command=command,
144
- exit_code=result.returncode,
145
- stdout_path=stdout_path,
146
- stderr_path=stderr_path,
147
- summary=f"Exit code {result.returncode}. stdout: {stdout_summary}. stderr: {stderr_summary}"
148
- )
149
- except Exception as e:
150
- raise ExecutionError(f"Command execution failed: {e}")
151
-
152
- def write_file(self, path: str, content: str, task: Task):
153
- """Write content to a file if allowed by permissions."""
154
- self._validate_path_within_root(path)
155
- full_path = resolve_project_path(self.project_root, path)
156
- operation: Literal["create", "modify"] = "modify" if full_path.exists() else "create"
157
- self.permissions.validate_action("file_write", path, task, operation=operation)
158
-
159
- logger.info(f"Writing authorized file: {path}")
160
- try:
161
- full_path.parent.mkdir(parents=True, exist_ok=True)
162
- full_path.write_text(content, encoding="utf-8")
163
- TraceLogger(self.project_root).log_event(
164
- "file_written",
165
- {"path": path},
166
- task_id=task.id,
167
- summary=f"Wrote {path}",
168
- )
169
- except Exception as e:
170
- raise ExecutionError(f"Failed to write file {path}: {e}")
@@ -1 +0,0 @@
1
-