Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/deepwork/core/policy_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ class PolicyParseError(Exception):
pass


# Valid compare_to values
COMPARE_TO_VALUES = frozenset({"base", "default_tip", "prompt"})
DEFAULT_COMPARE_TO = "base"


@dataclass
class Policy:
"""Represents a single policy definition."""
Expand All @@ -25,6 +30,7 @@ class Policy:
triggers: list[str] # Normalized to list
safety: list[str] = field(default_factory=list) # Normalized to list, empty if not specified
instructions: str = "" # Resolved content (either inline or from file)
compare_to: str = DEFAULT_COMPARE_TO # What to compare against: base, default_tip, or prompt

@classmethod
def from_dict(cls, data: dict[str, Any], base_dir: Path | None = None) -> "Policy":
Expand Down Expand Up @@ -74,11 +80,15 @@ def from_dict(cls, data: dict[str, Any], base_dir: Path | None = None) -> "Polic
f"Policy '{data['name']}' must have either 'instructions' or 'instructions_file'"
)

# Get compare_to (defaults to DEFAULT_COMPARE_TO)
compare_to = data.get("compare_to", DEFAULT_COMPARE_TO)

return cls(
name=data["name"],
triggers=triggers,
safety=safety,
instructions=instructions,
compare_to=compare_to,
)


Expand Down
257 changes: 237 additions & 20 deletions src/deepwork/hooks/evaluate_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@

Usage:
python -m deepwork.hooks.evaluate_policies \
--policy-file .deepwork.policy.yml \
--changed-files "file1.py\nfile2.py"
--policy-file .deepwork.policy.yml

The conversation context is read from stdin and checked for <promise> tags
that indicate policies have already been addressed.

Changed files are computed based on each policy's compare_to setting:
- base: Compare to merge-base with default branch (default)
- default_tip: Two-dot diff against default branch tip
- prompt: Compare to state captured at prompt submission

Output is JSON suitable for Claude Code Stop hooks:
{"decision": "block", "reason": "..."} # Block stop, policies need attention
{} # No policies fired, allow stop
Expand All @@ -20,16 +24,223 @@
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

from deepwork.core.policy_parser import (
Policy,
PolicyParseError,
evaluate_policies,
evaluate_policy,
parse_policy_file,
)


def get_default_branch() -> str:
"""
Get the default branch name (main or master).

Returns:
Default branch name, or "main" if cannot be determined.
"""
# Try to get the default branch from remote HEAD
try:
result = subprocess.run(
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
capture_output=True,
text=True,
check=True,
)
# Output is like "refs/remotes/origin/main"
return result.stdout.strip().split("/")[-1]
except subprocess.CalledProcessError:
pass

# Try common default branch names
for branch in ["main", "master"]:
try:
subprocess.run(
["git", "rev-parse", "--verify", f"origin/{branch}"],
capture_output=True,
check=True,
)
return branch
except subprocess.CalledProcessError:
continue

# Fall back to main
return "main"


def get_changed_files_base() -> list[str]:
"""
Get files changed relative to the base of the current branch.

This finds the merge-base between the current branch and the default branch,
then returns all files changed since that point.

Returns:
List of changed file paths.
"""
default_branch = get_default_branch()

try:
# Get the merge-base (where current branch diverged from default)
result = subprocess.run(
["git", "merge-base", "HEAD", f"origin/{default_branch}"],
capture_output=True,
text=True,
check=True,
)
merge_base = result.stdout.strip()

# Stage all changes so they appear in diff
subprocess.run(["git", "add", "-A"], capture_output=True, check=False)

# Get files changed since merge-base (including staged)
result = subprocess.run(
["git", "diff", "--name-only", merge_base, "HEAD"],
capture_output=True,
text=True,
check=True,
)
committed_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()

# Also get staged changes not yet committed
result = subprocess.run(
["git", "diff", "--name-only", "--cached"],
capture_output=True,
text=True,
check=False,
)
staged_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()

# Get untracked files
result = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard"],
capture_output=True,
text=True,
check=False,
)
untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()

all_files = committed_files | staged_files | untracked_files
return sorted([f for f in all_files if f])

except subprocess.CalledProcessError:
return []


def get_changed_files_default_tip() -> list[str]:
"""
Get files changed compared to the tip of the default branch.

This does a two-dot diff: what's different between HEAD and origin/default.

Returns:
List of changed file paths.
"""
default_branch = get_default_branch()

try:
# Stage all changes so they appear in diff
subprocess.run(["git", "add", "-A"], capture_output=True, check=False)

# Two-dot diff against default branch tip
result = subprocess.run(
["git", "diff", "--name-only", f"origin/{default_branch}..HEAD"],
capture_output=True,
text=True,
check=True,
)
committed_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()

# Also get staged changes not yet committed
result = subprocess.run(
["git", "diff", "--name-only", "--cached"],
capture_output=True,
text=True,
check=False,
)
staged_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()

# Get untracked files
result = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard"],
capture_output=True,
text=True,
check=False,
)
untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()

all_files = committed_files | staged_files | untracked_files
return sorted([f for f in all_files if f])

except subprocess.CalledProcessError:
return []


def get_changed_files_prompt() -> list[str]:
"""
Get files changed since the prompt was submitted.

This compares against the baseline captured by capture_prompt_work_tree.sh.

Returns:
List of changed file paths.
"""
baseline_path = Path(".deepwork/.last_work_tree")

try:
# Stage all changes so we can see them with --cached
subprocess.run(["git", "add", "-A"], capture_output=True, check=False)

# Get all staged files (includes what was just staged)
result = subprocess.run(
["git", "diff", "--name-only", "--cached"],
capture_output=True,
text=True,
check=False,
)
current_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
current_files = {f for f in current_files if f}

if baseline_path.exists():
# Read baseline and find new files
baseline_files = set(baseline_path.read_text().strip().split("\n"))
baseline_files = {f for f in baseline_files if f}
# Return files that are in current but not in baseline
new_files = current_files - baseline_files
return sorted(new_files)
else:
# No baseline, return all current changes
return sorted(current_files)

except (subprocess.CalledProcessError, OSError):
return []


def get_changed_files_for_mode(mode: str) -> list[str]:
"""
Get changed files for a specific compare_to mode.

Args:
mode: One of 'base', 'default_tip', or 'prompt'

Returns:
List of changed file paths.
"""
if mode == "base":
return get_changed_files_base()
elif mode == "default_tip":
return get_changed_files_default_tip()
elif mode == "prompt":
return get_changed_files_prompt()
else:
# Unknown mode, fall back to base
return get_changed_files_base()


def extract_promise_tags(text: str) -> set[str]:
"""
Extract policy names from <promise> tags in text.
Expand Down Expand Up @@ -87,23 +298,9 @@ def main() -> None:
required=True,
help="Path to .deepwork.policy.yml file",
)
parser.add_argument(
"--changed-files",
type=str,
required=True,
help="Newline-separated list of changed files",
)

args = parser.parse_args()

# Parse changed files (newline-separated)
changed_files = [f.strip() for f in args.changed_files.split("\n") if f.strip()]

if not changed_files:
# No files changed, nothing to evaluate
print("{}")
return

# Check if policy file exists
policy_path = Path(args.policy_file)
if not policy_path.exists():
Expand All @@ -122,7 +319,7 @@ def main() -> None:
# Extract promise tags from conversation
promised_policies = extract_promise_tags(conversation_context)

# Parse and evaluate policies
# Parse policies
try:
policies = parse_policy_file(policy_path)
except PolicyParseError as e:
Expand All @@ -136,8 +333,28 @@ def main() -> None:
print("{}")
return

# Evaluate which policies fire
fired_policies = evaluate_policies(policies, changed_files, promised_policies)
# Group policies by compare_to mode to minimize git calls
policies_by_mode: dict[str, list[Policy]] = {}
for policy in policies:
mode = policy.compare_to
if mode not in policies_by_mode:
policies_by_mode[mode] = []
policies_by_mode[mode].append(policy)

# Get changed files for each mode and evaluate policies
fired_policies: list[Policy] = []
for mode, mode_policies in policies_by_mode.items():
changed_files = get_changed_files_for_mode(mode)
if not changed_files:
continue

for policy in mode_policies:
# Skip if already promised
if policy.name in promised_policies:
continue
# Evaluate this policy
if evaluate_policy(policy, changed_files):
fired_policies.append(policy)

if not fired_policies:
# No policies fired
Expand Down
10 changes: 10 additions & 0 deletions src/deepwork/schemas/policy_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@
"minLength": 1,
"description": "Path to a file containing instructions (alternative to inline instructions)",
},
"compare_to": {
"type": "string",
"enum": ["base", "default_tip", "prompt"],
"description": (
"What to compare against when detecting changed files. "
"'base' (default) compares to the base of the current branch. "
"'default_tip' compares to the tip of the default branch. "
"'prompt' compares to the state at the start of the prompt."
),
},
},
"oneOf": [
{"required": ["instructions"]},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/bin/bash
# capture_work_tree.sh - Captures the current git work tree state
# capture_prompt_work_tree.sh - Captures the git work tree state at prompt submission
#
# This script creates a snapshot of the current git state by recording
# all files that have been modified, added, or deleted. This baseline
# is used later to detect what changed during an agent session.
# is used for policies with compare_to: prompt to detect what changed
# during an agent response (between user prompts).

set -e

Expand Down
Loading