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.
- package/README.md +191 -253
- package/bin/devcouncil.js +130 -32
- package/package.json +6 -17
- package/pyproject.toml +0 -66
- package/src/devcouncil/__init__.py +0 -0
- package/src/devcouncil/__main__.py +0 -4
- package/src/devcouncil/app/__init__.py +0 -28
- package/src/devcouncil/app/config.py +0 -320
- package/src/devcouncil/app/errors.py +0 -23
- package/src/devcouncil/app/events.py +0 -44
- package/src/devcouncil/app/orchestrator.py +0 -92
- package/src/devcouncil/app/project_status.py +0 -29
- package/src/devcouncil/app/run_context.py +0 -39
- package/src/devcouncil/app/state_machine.py +0 -108
- package/src/devcouncil/artifacts/__init__.py +0 -1
- package/src/devcouncil/artifacts/coverage.py +0 -96
- package/src/devcouncil/artifacts/graph.py +0 -163
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/artifacts/validators.py +0 -27
- package/src/devcouncil/assets/__init__.py +0 -1
- package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/__init__.py +0 -0
- package/src/devcouncil/cli/commands/__init__.py +0 -0
- package/src/devcouncil/cli/commands/agents.py +0 -292
- package/src/devcouncil/cli/commands/artifacts.py +0 -54
- package/src/devcouncil/cli/commands/ast.py +0 -22
- package/src/devcouncil/cli/commands/baseline.py +0 -35
- package/src/devcouncil/cli/commands/check.py +0 -209
- package/src/devcouncil/cli/commands/config.py +0 -115
- package/src/devcouncil/cli/commands/cost.py +0 -57
- package/src/devcouncil/cli/commands/dashboard.py +0 -31
- package/src/devcouncil/cli/commands/doctor.py +0 -340
- package/src/devcouncil/cli/commands/evidence.py +0 -48
- package/src/devcouncil/cli/commands/go.py +0 -656
- package/src/devcouncil/cli/commands/handoff.py +0 -69
- package/src/devcouncil/cli/commands/hook.py +0 -237
- package/src/devcouncil/cli/commands/init.py +0 -289
- package/src/devcouncil/cli/commands/integrate.py +0 -1515
- package/src/devcouncil/cli/commands/lsp.py +0 -20
- package/src/devcouncil/cli/commands/map.py +0 -112
- package/src/devcouncil/cli/commands/mcp_server.py +0 -18
- package/src/devcouncil/cli/commands/plan.py +0 -488
- package/src/devcouncil/cli/commands/prompt.py +0 -61
- package/src/devcouncil/cli/commands/repair.py +0 -89
- package/src/devcouncil/cli/commands/report.py +0 -137
- package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
- package/src/devcouncil/cli/commands/rollback.py +0 -59
- package/src/devcouncil/cli/commands/run.py +0 -289
- package/src/devcouncil/cli/commands/runs.py +0 -223
- package/src/devcouncil/cli/commands/scaffold.py +0 -32
- package/src/devcouncil/cli/commands/semantic.py +0 -47
- package/src/devcouncil/cli/commands/setup.py +0 -362
- package/src/devcouncil/cli/commands/shell.py +0 -73
- package/src/devcouncil/cli/commands/show.py +0 -76
- package/src/devcouncil/cli/commands/skills.py +0 -88
- package/src/devcouncil/cli/commands/status.py +0 -141
- package/src/devcouncil/cli/commands/tasks.py +0 -55
- package/src/devcouncil/cli/commands/trace.py +0 -88
- package/src/devcouncil/cli/commands/verify.py +0 -328
- package/src/devcouncil/cli/commands/version.py +0 -20
- package/src/devcouncil/cli/commands/watch.py +0 -574
- package/src/devcouncil/cli/commands/watch_fs.py +0 -40
- package/src/devcouncil/cli/main.py +0 -137
- package/src/devcouncil/council/__init__.py +0 -0
- package/src/devcouncil/council/prompts/__init__.py +0 -0
- package/src/devcouncil/council/prompts/arbiter.md +0 -19
- package/src/devcouncil/council/prompts/critic_a.md +0 -10
- package/src/devcouncil/council/prompts/critic_b.md +0 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
- package/src/devcouncil/council/prompts/planner_a.md +0 -16
- package/src/devcouncil/council/prompts/planner_b.md +0 -16
- package/src/devcouncil/council/prompts/rebuttal.md +0 -10
- package/src/devcouncil/council/prompts/spec_writer.md +0 -12
- package/src/devcouncil/domain/__init__.py +0 -0
- package/src/devcouncil/domain/assumption.py +0 -17
- package/src/devcouncil/domain/critique.py +0 -32
- package/src/devcouncil/domain/evidence.py +0 -47
- package/src/devcouncil/domain/gap.py +0 -52
- package/src/devcouncil/domain/requirement.py +0 -22
- package/src/devcouncil/domain/task.py +0 -55
- package/src/devcouncil/execution/__init__.py +0 -1
- package/src/devcouncil/execution/checkpoints.py +0 -246
- package/src/devcouncil/execution/context_builder.py +0 -60
- package/src/devcouncil/execution/executor.py +0 -15
- package/src/devcouncil/execution/fs_watcher.py +0 -180
- package/src/devcouncil/execution/handoff.py +0 -102
- package/src/devcouncil/execution/hook_policy.py +0 -253
- package/src/devcouncil/execution/patch.py +0 -77
- package/src/devcouncil/execution/paths.py +0 -14
- package/src/devcouncil/execution/permissions.py +0 -85
- package/src/devcouncil/execution/policy_engine.py +0 -343
- package/src/devcouncil/execution/prompt_builder.py +0 -671
- package/src/devcouncil/execution/shell_session.py +0 -225
- package/src/devcouncil/execution/task_runner.py +0 -170
- package/src/devcouncil/executors/__init__.py +0 -1
- package/src/devcouncil/executors/agent_registry.py +0 -575
- package/src/devcouncil/executors/coding_cli.py +0 -736
- package/src/devcouncil/executors/mini_swe.py +0 -73
- package/src/devcouncil/executors/native/__init__.py +0 -0
- package/src/devcouncil/executors/native/agent.py +0 -208
- package/src/devcouncil/executors/openhands.py +0 -71
- package/src/devcouncil/gating/__init__.py +0 -1
- package/src/devcouncil/gating/checks/__init__.py +0 -0
- package/src/devcouncil/gating/checks/clean_git.py +0 -52
- package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
- package/src/devcouncil/gating/policy.py +0 -338
- package/src/devcouncil/hardware.py +0 -184
- package/src/devcouncil/indexing/__init__.py +0 -1
- package/src/devcouncil/indexing/ast_matcher.py +0 -168
- package/src/devcouncil/indexing/graph_index.py +0 -48
- package/src/devcouncil/indexing/lsp.py +0 -161
- package/src/devcouncil/indexing/repo_mapper.py +0 -1455
- package/src/devcouncil/indexing/semantic_index.py +0 -205
- package/src/devcouncil/integrations/actions.py +0 -146
- package/src/devcouncil/integrations/check.py +0 -423
- package/src/devcouncil/integrations/code_review_graph.py +0 -163
- package/src/devcouncil/integrations/github.py +0 -39
- package/src/devcouncil/integrations/github_intent.py +0 -142
- package/src/devcouncil/integrations/gitnexus.py +0 -62
- package/src/devcouncil/integrations/graphify.py +0 -34
- package/src/devcouncil/integrations/mcp/__init__.py +0 -0
- package/src/devcouncil/integrations/mcp/server.py +0 -2122
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
- package/src/devcouncil/integrations/pr_comments.py +0 -62
- package/src/devcouncil/live/__init__.py +0 -2
- package/src/devcouncil/live/cards.py +0 -349
- package/src/devcouncil/live/models.py +0 -63
- package/src/devcouncil/live/repair_prompt.py +0 -83
- package/src/devcouncil/live/reviewer.py +0 -70
- package/src/devcouncil/live/signals.py +0 -135
- package/src/devcouncil/live/summary.py +0 -34
- package/src/devcouncil/live/tasks.py +0 -18
- package/src/devcouncil/live/transcripts.py +0 -141
- package/src/devcouncil/llm/__init__.py +0 -1
- package/src/devcouncil/llm/cache.py +0 -42
- package/src/devcouncil/llm/model_defaults.yaml +0 -44
- package/src/devcouncil/llm/provider.py +0 -627
- package/src/devcouncil/llm/router.py +0 -310
- package/src/devcouncil/optimization/__init__.py +0 -1
- package/src/devcouncil/optimization/gepa_agent.py +0 -318
- package/src/devcouncil/planning/__init__.py +0 -1
- package/src/devcouncil/planning/arbiter_service.py +0 -57
- package/src/devcouncil/planning/correction_manifest.py +0 -303
- package/src/devcouncil/planning/critique_service.py +0 -71
- package/src/devcouncil/planning/plan_service.py +0 -60
- package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
- package/src/devcouncil/planning/repair_service.py +0 -39
- package/src/devcouncil/planning/spec_service.py +0 -70
- package/src/devcouncil/repo/__init__.py +0 -0
- package/src/devcouncil/repo/ci_scaffold.py +0 -157
- package/src/devcouncil/repo/gitignore.py +0 -123
- package/src/devcouncil/repo/sca.py +0 -374
- package/src/devcouncil/reporting/__init__.py +0 -0
- package/src/devcouncil/reporting/github_check.py +0 -32
- package/src/devcouncil/reporting/json_report.py +0 -30
- package/src/devcouncil/reporting/markdown_report.py +0 -83
- package/src/devcouncil/reporting/report_builder.py +0 -14
- package/src/devcouncil/skills/__init__.py +0 -19
- package/src/devcouncil/skills/library/README.md +0 -46
- package/src/devcouncil/skills/library/ai-training.md +0 -50
- package/src/devcouncil/skills/library/android.md +0 -50
- package/src/devcouncil/skills/library/backend.md +0 -52
- package/src/devcouncil/skills/library/core-engineering.md +0 -95
- package/src/devcouncil/skills/library/data-engineering.md +0 -47
- package/src/devcouncil/skills/library/desktop.md +0 -46
- package/src/devcouncil/skills/library/devops.md +0 -48
- package/src/devcouncil/skills/library/game-dev.md +0 -46
- package/src/devcouncil/skills/library/ios.md +0 -48
- package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
- package/src/devcouncil/skills/library/security.md +0 -48
- package/src/devcouncil/skills/library/systems.md +0 -48
- package/src/devcouncil/skills/library/web.md +0 -47
- package/src/devcouncil/skills/library/windows.md +0 -47
- package/src/devcouncil/skills/registry.py +0 -330
- package/src/devcouncil/storage/__init__.py +0 -0
- package/src/devcouncil/storage/db.py +0 -147
- package/src/devcouncil/storage/models.py +0 -204
- package/src/devcouncil/storage/native.py +0 -557
- package/src/devcouncil/storage/repositories.py +0 -485
- package/src/devcouncil/telemetry/__init__.py +0 -0
- package/src/devcouncil/telemetry/cost.py +0 -140
- package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
- package/src/devcouncil/telemetry/pricing.py +0 -28
- package/src/devcouncil/telemetry/traces.py +0 -146
- package/src/devcouncil/telemetry/tracker.py +0 -52
- package/src/devcouncil/ui/__init__.py +0 -1
- package/src/devcouncil/ui/dashboard.py +0 -423
- package/src/devcouncil/utils/__init__.py +0 -1
- package/src/devcouncil/utils/redaction.py +0 -147
- package/src/devcouncil/utils/subprocess_env.py +0 -69
- package/src/devcouncil/verification/__init__.py +0 -1
- package/src/devcouncil/verification/acceptance_compiler.py +0 -125
- package/src/devcouncil/verification/ad_hoc_check.py +0 -129
- package/src/devcouncil/verification/diff_coverage.py +0 -353
- package/src/devcouncil/verification/implementation_reviewer.py +0 -55
- package/src/devcouncil/verification/next_actions.py +0 -189
- package/src/devcouncil/verification/sandbox.py +0 -178
- package/src/devcouncil/verification/test_resolver.py +0 -91
- package/src/devcouncil/verification/verifier.py +0 -1548
- 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
|
-
|