Skip to content

Commit 549cfa5

Browse files
committed
Implement policy system v2 with sets, pairs, and command actions
This implements the redesigned policy system with: - Detection modes: trigger/safety (default), set (bidirectional), pair (directional) - Action types: prompt (show instructions), command (run idempotent command) - Variable pattern matching: {path} for multi-segment, {name} for single-segment - Queue system in .deepwork/tmp/policy/queue/ for state tracking - Frontmatter markdown format for policy files in .deepwork/policies/ New core modules: - pattern_matcher.py: Variable pattern matching with regex - policy_queue.py: Queue system for policy state persistence - command_executor.py: Command action execution with substitution Updates to existing modules: - policy_parser.py: v2 Policy class with detection modes and action types - policy_check.py: Uses new v2 system with queue deduplication - evaluate_policies.py: Updated for v1 backward compatibility - policy_schema.py: New frontmatter schema for v2 format Tests updated to work with both v1 and v2 APIs.
1 parent 113a5ee commit 549cfa5

9 files changed

Lines changed: 1638 additions & 213 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Execute command actions for policies."""
2+
3+
import subprocess
4+
from dataclasses import dataclass
5+
from pathlib import Path
6+
7+
from deepwork.core.policy_parser import CommandAction
8+
9+
10+
@dataclass
11+
class CommandResult:
12+
"""Result of executing a command."""
13+
14+
success: bool
15+
exit_code: int
16+
stdout: str
17+
stderr: str
18+
command: str # The actual command that was run
19+
20+
21+
def substitute_command_variables(
22+
command_template: str,
23+
file: str | None = None,
24+
files: list[str] | None = None,
25+
repo_root: Path | None = None,
26+
) -> str:
27+
"""
28+
Substitute template variables in a command string.
29+
30+
Variables:
31+
- {file} - Single file path
32+
- {files} - Space-separated file paths
33+
- {repo_root} - Repository root directory
34+
35+
Args:
36+
command_template: Command string with {var} placeholders
37+
file: Single file path (for run_for: each_match)
38+
files: List of file paths (for run_for: all_matches)
39+
repo_root: Repository root path
40+
41+
Returns:
42+
Command string with variables substituted
43+
"""
44+
result = command_template
45+
46+
if file is not None:
47+
result = result.replace("{file}", file)
48+
49+
if files is not None:
50+
result = result.replace("{files}", " ".join(files))
51+
52+
if repo_root is not None:
53+
result = result.replace("{repo_root}", str(repo_root))
54+
55+
return result
56+
57+
58+
def execute_command(
59+
command: str,
60+
cwd: Path | None = None,
61+
timeout: int = 60,
62+
) -> CommandResult:
63+
"""
64+
Execute a command and capture output.
65+
66+
Args:
67+
command: Command string to execute
68+
cwd: Working directory (defaults to current directory)
69+
timeout: Timeout in seconds
70+
71+
Returns:
72+
CommandResult with execution details
73+
"""
74+
try:
75+
# Run command as shell to support pipes, etc.
76+
result = subprocess.run(
77+
command,
78+
shell=True,
79+
cwd=cwd,
80+
capture_output=True,
81+
text=True,
82+
timeout=timeout,
83+
)
84+
85+
return CommandResult(
86+
success=result.returncode == 0,
87+
exit_code=result.returncode,
88+
stdout=result.stdout,
89+
stderr=result.stderr,
90+
command=command,
91+
)
92+
93+
except subprocess.TimeoutExpired:
94+
return CommandResult(
95+
success=False,
96+
exit_code=-1,
97+
stdout="",
98+
stderr=f"Command timed out after {timeout} seconds",
99+
command=command,
100+
)
101+
except Exception as e:
102+
return CommandResult(
103+
success=False,
104+
exit_code=-1,
105+
stdout="",
106+
stderr=str(e),
107+
command=command,
108+
)
109+
110+
111+
def run_command_action(
112+
action: CommandAction,
113+
trigger_files: list[str],
114+
repo_root: Path | None = None,
115+
) -> list[CommandResult]:
116+
"""
117+
Run a command action for the given trigger files.
118+
119+
Args:
120+
action: CommandAction configuration
121+
trigger_files: Files that triggered the policy
122+
repo_root: Repository root path
123+
124+
Returns:
125+
List of CommandResult (one per command execution)
126+
"""
127+
results: list[CommandResult] = []
128+
129+
if action.run_for == "each_match":
130+
# Run command for each file individually
131+
for file_path in trigger_files:
132+
command = substitute_command_variables(
133+
action.command,
134+
file=file_path,
135+
repo_root=repo_root,
136+
)
137+
result = execute_command(command, cwd=repo_root)
138+
results.append(result)
139+
140+
elif action.run_for == "all_matches":
141+
# Run command once with all files
142+
command = substitute_command_variables(
143+
action.command,
144+
files=trigger_files,
145+
repo_root=repo_root,
146+
)
147+
result = execute_command(command, cwd=repo_root)
148+
results.append(result)
149+
150+
return results
151+
152+
153+
def all_commands_succeeded(results: list[CommandResult]) -> bool:
154+
"""Check if all command executions succeeded."""
155+
return all(r.success for r in results)
156+
157+
158+
def format_command_errors(results: list[CommandResult]) -> str:
159+
"""Format error messages from failed commands."""
160+
errors: list[str] = []
161+
for result in results:
162+
if not result.success:
163+
msg = f"Command failed: {result.command}\n"
164+
if result.stderr:
165+
msg += f"Error: {result.stderr}\n"
166+
if result.exit_code != 0:
167+
msg += f"Exit code: {result.exit_code}\n"
168+
errors.append(msg)
169+
return "\n".join(errors)

0 commit comments

Comments
 (0)