diff --git a/.claude/commands/AGENTS.md b/.claude/commands/AGENTS.md
new file mode 100644
index 00000000..8e7ac73c
--- /dev/null
+++ b/.claude/commands/AGENTS.md
@@ -0,0 +1,5 @@
+# Auto-Generated Commands
+
+All command files in this directory are auto-generated by `deepwork-sync` and should not be manually edited. Changes will be overwritten on the next sync.
+
+If you need to modify a command, update the source configuration and re-run the sync process.
diff --git a/.claude/commands/deepwork_jobs.define.md b/.claude/commands/deepwork_jobs.define.md
index e600d63b..54ac6027 100644
--- a/.claude/commands/deepwork_jobs.define.md
+++ b/.claude/commands/deepwork_jobs.define.md
@@ -329,7 +329,6 @@ After creating the file:
- Description provides rich context for future refinement
- Specification is valid YAML and follows the schema
- Ready for implementation step
-- Output artifacts have quality criteria defined
## Inputs
@@ -364,18 +363,24 @@ Create the following output(s) in the work directory:
- `deepwork/deepwork_jobs/job.yml`
Ensure all outputs are:
- Well-formatted and complete
+- Committed to the work branch
- Ready for review or use by subsequent steps
## Completion
After completing this step:
-1. **Verify outputs**: Confirm all required files have been created
+1. **Commit your work**:
+ ```bash
+ git add deepwork/deepwork_jobs/
+ git commit -m "deepwork_jobs: Complete define step"
+ ```
+
+2. **Verify outputs**: Confirm all required files have been created
-2. **Inform the user**:
- - Step 1 of 2 is complete
+3. **Inform the user**:
+ - Step 1 of 3 is complete
- Outputs created: job.yml
- - They should review the job.yml file before proceeding
- Ready to proceed to next step: `/deepwork_jobs.implement`
## Next Step
diff --git a/.claude/commands/deepwork_jobs.implement.md b/.claude/commands/deepwork_jobs.implement.md
index cc3de87c..dc3be9ef 100644
--- a/.claude/commands/deepwork_jobs.implement.md
+++ b/.claude/commands/deepwork_jobs.implement.md
@@ -380,8 +380,8 @@ Before marking this step complete, ensure:
- [ ] Registry updated with new job
- [ ] `deepwork sync` executed successfully
- [ ] Commands generated in platform directory
-- [ ] Reload the slash commmands so the user can use them immediately if the current CLI requires it
- [ ] User informed of next steps (reload commands)
+- [ ] implementation_summary.md created
## Quality Criteria
@@ -430,19 +430,25 @@ Create the following output(s) in the work directory:
- `deepwork/deepwork_jobs/implementation_summary.md`
Ensure all outputs are:
- Well-formatted and complete
+- Committed to the work branch
- Ready for review or use by subsequent steps
## Completion
After completing this step:
-1. **Verify outputs**: Confirm all required files have been created
+1. **Commit your work**:
+ ```bash
+ git add deepwork/deepwork_jobs/
+ git commit -m "deepwork_jobs: Complete implement step"
+ ```
+
+2. **Verify outputs**: Confirm all required files have been created
-2. **Inform the user**:
- - All steps are complete
- - They should review the generated files before proceeding
- - Suggest that before they try the job, they should commit the changes so far.
- - Remind them what the first command for their new job will be to run it
+3. **Inform the user**:
+ - Step 2 of 3 is complete
+ - Outputs created: implementation_summary.md
+ - Ready to proceed to next step: `/deepwork_jobs.refine`
## Next Step
diff --git a/.claude/commands/deepwork_jobs.refine.md b/.claude/commands/deepwork_jobs.refine.md
index bc4bb496..1bc41c9b 100644
--- a/.claude/commands/deepwork_jobs.refine.md
+++ b/.claude/commands/deepwork_jobs.refine.md
@@ -4,7 +4,7 @@ description: Modify an existing job definition
# deepwork_jobs.refine
-**Step 3 of 3** in the **deepwork_jobs** workflow
+**Standalone command** in the **deepwork_jobs** job - can be run anytime
**Summary**: DeepWork job management commands
@@ -439,31 +439,38 @@ All work for this job should be done on a dedicated work branch:
## Output Requirements
-**No separate output file is created**. All changes are made directly to:
-- `.deepwork/jobs/[job_name]/job.yml` (updated with new version and changelog entry)
-- `.deepwork/jobs/[job_name]/steps/[step_id].md` (if step instructions are modified)
-- Any new step instruction files created
+Create the following output(s) in the work directory:
+- `deepwork/deepwork_jobs/job.yml`
+Ensure all outputs are:
+- Well-formatted and complete
+- Committed to the work branch
+- Ready for review or use by subsequent steps
## Completion
After completing this step:
-1. **Verify outputs**: Confirm all required files have been created
+1. **Commit your work**:
+ ```bash
+ git add deepwork/deepwork_jobs/
+ git commit -m "deepwork_jobs: Complete refine step"
+ ```
+
+2. **Verify outputs**: Confirm all required files have been created
-2. **Inform the user**:
- - Step 3 of 3 is complete
- - Changes made to job.yml with updated version and changelog
- - They should review the updated job.yml file before proceeding
- - This is the final step - the job is complete!
+3. **Inform the user**:
+ - The refine command is complete
+ - Outputs created: job.yml
+ - This command can be run again anytime to make further changes
-## Workflow Complete
+## Command Complete
-This is the final step in the deepwork_jobs workflow. All outputs should now be complete and ready for review.
+This is a standalone command that can be run anytime. The outputs are ready for use.
Consider:
-- Reviewing all work products in `deepwork/deepwork_jobs/`
-- Creating a pull request to merge the work branch
-- Documenting any insights or learnings
+- Reviewing the outputs in `deepwork/deepwork_jobs/`
+- Running `deepwork sync` if job definitions were changed
+- Re-running this command later if further changes are needed
---
diff --git a/.claude/commands/deepwork_policy.define.md b/.claude/commands/deepwork_policy.define.md
new file mode 100644
index 00000000..4269712a
--- /dev/null
+++ b/.claude/commands/deepwork_policy.define.md
@@ -0,0 +1,275 @@
+---
+description: Create or update policy entries in .deepwork.policy.yml
+---
+
+# deepwork_policy.define
+
+**Standalone command** in the **deepwork_policy** job - can be run anytime
+
+**Summary**: Policy enforcement for AI agent sessions
+
+## Job Overview
+
+Manages policies that automatically trigger when certain files change during an AI agent session.
+Policies help ensure that code changes follow team guidelines, documentation is updated,
+and architectural decisions are respected.
+
+Policies are defined in a `.deepwork.policy.yml` file at the root of your project. Each policy
+specifies:
+- Trigger patterns: Glob patterns for files that, when changed, should trigger the policy
+- Safety patterns: Glob patterns for files that, if also changed, mean the policy doesn't need to fire
+- Instructions: What the agent should do when the policy triggers
+
+Example use cases:
+- Update installation docs when configuration files change
+- Require security review when authentication code is modified
+- Ensure API documentation stays in sync with API code
+- Remind developers to update changelogs
+
+
+
+## Instructions
+
+# Define Policy
+
+## Objective
+
+Create or update policy entries in the `.deepwork.policy.yml` file to enforce team guidelines, documentation requirements, or other constraints when specific files change.
+
+## Task
+
+Guide the user through defining a new policy by asking clarifying questions. **Do not create the policy without first understanding what they want to enforce.**
+
+### Step 1: Understand the Policy Purpose
+
+Start by asking questions to understand what the user wants to enforce:
+
+1. **What guideline or constraint should this policy enforce?**
+ - What situation triggers the need for action?
+ - What files or directories, when changed, should trigger this policy?
+ - Examples: "When config files change", "When API code changes", "When database schema changes"
+
+2. **What action should be taken?**
+ - What should the agent do when the policy triggers?
+ - Update documentation? Perform a security review? Update tests?
+ - Is there a specific file or process that needs attention?
+
+3. **Are there any "safety" conditions?**
+ - Are there files that, if also changed, mean the policy doesn't need to fire?
+ - For example: If config changes AND install_guide.md changes, assume docs are already updated
+ - This prevents redundant prompts when the user has already done the right thing
+
+### Step 2: Define the Trigger Patterns
+
+Help the user define glob patterns for files that should trigger the policy:
+
+**Common patterns:**
+- `src/**/*.py` - All Python files in src directory (recursive)
+- `app/config/**/*` - All files in app/config directory
+- `*.md` - All markdown files in root
+- `src/api/**/*` - All files in the API directory
+- `migrations/**/*.sql` - All SQL migrations
+
+**Pattern syntax:**
+- `*` - Matches any characters within a single path segment
+- `**` - Matches any characters across multiple path segments (recursive)
+- `?` - Matches a single character
+
+### Step 3: Define Safety Patterns (Optional)
+
+If there are files that, when also changed, mean the policy shouldn't fire:
+
+**Examples:**
+- Policy: "Update install guide when config changes"
+ - Trigger: `app/config/**/*`
+ - Safety: `docs/install_guide.md` (if already updated, don't prompt)
+
+- Policy: "Security review for auth changes"
+ - Trigger: `src/auth/**/*`
+ - Safety: `SECURITY.md`, `docs/security_review.md`
+
+### Step 4: Write the Instructions
+
+Create clear, actionable instructions for what the agent should do when the policy fires.
+
+**Good instructions include:**
+- What to check or review
+- What files might need updating
+- Specific actions to take
+- Quality criteria for completion
+
+**Example:**
+```
+Configuration files have changed. Please:
+1. Review docs/install_guide.md for accuracy
+2. Update any installation steps that reference changed config
+3. Verify environment variable documentation is current
+4. Test that installation instructions still work
+```
+
+### Step 5: Create the Policy Entry
+
+Create or update `.deepwork.policy.yml` in the project root.
+
+**File Location**: `.deepwork.policy.yml` (root of project)
+
+**Format**:
+```yaml
+- name: "[Friendly name for the policy]"
+ trigger: "[glob pattern]" # or array: ["pattern1", "pattern2"]
+ safety: "[glob pattern]" # optional, or array
+ instructions: |
+ [Multi-line instructions for the agent...]
+```
+
+**Alternative with instructions_file**:
+```yaml
+- name: "[Friendly name for the policy]"
+ trigger: "[glob pattern]"
+ safety: "[glob pattern]"
+ instructions_file: "path/to/instructions.md"
+```
+
+### Step 6: Verify the Policy
+
+After creating the policy:
+
+1. **Check the YAML syntax** - Ensure valid YAML formatting
+2. **Test trigger patterns** - Verify patterns match intended files
+3. **Review instructions** - Ensure they're clear and actionable
+4. **Check for conflicts** - Ensure the policy doesn't conflict with existing ones
+
+## Example Policies
+
+### Update Documentation on Config Changes
+```yaml
+- name: "Update install guide on config changes"
+ trigger: "app/config/**/*"
+ safety: "docs/install_guide.md"
+ instructions: |
+ Configuration files have been modified. Please review docs/install_guide.md
+ and update it if any installation instructions need to change based on the
+ new configuration.
+```
+
+### Security Review for Auth Code
+```yaml
+- name: "Security review for authentication changes"
+ trigger:
+ - "src/auth/**/*"
+ - "src/security/**/*"
+ safety:
+ - "SECURITY.md"
+ - "docs/security_audit.md"
+ instructions: |
+ Authentication or security code has been changed. Please:
+ 1. Review for hardcoded credentials or secrets
+ 2. Check input validation on user inputs
+ 3. Verify access control logic is correct
+ 4. Update security documentation if needed
+```
+
+### API Documentation Sync
+```yaml
+- name: "API documentation update"
+ trigger: "src/api/**/*.py"
+ safety: "docs/api/**/*.md"
+ instructions: |
+ API code has changed. Please verify that API documentation in docs/api/
+ is up to date with the code changes. Pay special attention to:
+ - New or changed endpoints
+ - Modified request/response schemas
+ - Updated authentication requirements
+```
+
+## Output Format
+
+### .deepwork.policy.yml
+Create or update this file at the project root with the new policy entry.
+
+## Quality Criteria
+
+- Policy name is clear and descriptive
+- Trigger patterns accurately match the intended files
+- Safety patterns prevent unnecessary triggering
+- Instructions are actionable and specific
+- YAML is valid and properly formatted
+
+## Context
+
+Policies are evaluated automatically when you finish working on a task. The system:
+1. Tracks which files you changed during the session
+2. Checks if any changes match policy trigger patterns
+3. Skips policies where safety patterns also matched
+4. Prompts you with instructions for any triggered policies
+
+You can mark a policy as addressed by including `considered` in your response. This tells the system you've already handled that policy's requirements.
+
+
+## Inputs
+
+### User Parameters
+
+Please gather the following information from the user:
+- **policy_purpose**: What guideline or constraint should this policy enforce?
+
+
+## Work Branch Management
+
+All work for this job should be done on a dedicated work branch:
+
+1. **Check current branch**:
+ - If already on a work branch for this job (format: `deepwork/deepwork_policy-[instance]-[date]`), continue using it
+ - If on main/master, create a new work branch
+
+2. **Create work branch** (if needed):
+ ```bash
+ git checkout -b deepwork/deepwork_policy-[instance]-$(date +%Y%m%d)
+ ```
+ Replace `[instance]` with a descriptive identifier (e.g., `acme`, `q1-launch`, etc.)
+
+3. **All outputs go in the work directory**:
+ - Create files in: `deepwork/deepwork_policy/`
+ - This keeps work products organized by job
+
+## Output Requirements
+
+Create the following output(s) in the work directory:
+- `deepwork/deepwork_policy/.deepwork.policy.yml`
+Ensure all outputs are:
+- Well-formatted and complete
+- Committed to the work branch
+- Ready for review or use by subsequent steps
+
+## Completion
+
+After completing this step:
+
+1. **Commit your work**:
+ ```bash
+ git add deepwork/deepwork_policy/
+ git commit -m "deepwork_policy: Complete define step"
+ ```
+
+2. **Verify outputs**: Confirm all required files have been created
+
+3. **Inform the user**:
+ - The define command is complete
+ - Outputs created: .deepwork.policy.yml
+ - This command can be run again anytime to make further changes
+
+## Command Complete
+
+This is a standalone command that can be run anytime. The outputs are ready for use.
+
+Consider:
+- Reviewing the outputs in `deepwork/deepwork_policy/`
+- Running `deepwork sync` if job definitions were changed
+- Re-running this command later if further changes are needed
+
+---
+
+## Context Files
+
+- Job definition: `.deepwork/jobs/deepwork_policy/job.yml`
+- Step instructions: `.deepwork/jobs/deepwork_policy/steps/define.md`
\ No newline at end of file
diff --git a/.claude/settings.json b/.claude/settings.json
index fec38f8f..4b7a20e6 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -89,5 +89,29 @@
"Bash(npx:*)",
"Edit(./**)"
]
+ },
+ "hooks": {
+ "UserPromptSubmit": [
+ {
+ "matcher": "",
+ "hooks": [
+ {
+ "type": "command",
+ "command": ".deepwork/jobs/deepwork_policy/hooks/user_prompt_submit.sh"
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "matcher": "",
+ "hooks": [
+ {
+ "type": "command",
+ "command": ".deepwork/jobs/deepwork_policy/hooks/policy_stop_hook.sh"
+ }
+ ]
+ }
+ ]
}
}
\ No newline at end of file
diff --git a/.deepwork.policy.yml b/.deepwork.policy.yml
new file mode 100644
index 00000000..f91a1c22
--- /dev/null
+++ b/.deepwork.policy.yml
@@ -0,0 +1,19 @@
+- name: "README Accuracy"
+ trigger: "src/**/*"
+ safety: "README.md"
+ instructions: |
+ Source code in src/ has been modified. Please review README.md for accuracy:
+ 1. Verify project overview still reflects current functionality
+ 2. Check that usage examples are still correct
+ 3. Ensure installation/setup instructions remain valid
+ 4. Update any sections that reference changed code
+
+- name: "Architecture Documentation Accuracy"
+ trigger: "src/**/*"
+ safety: "doc/architecture.md"
+ instructions: |
+ Source code in src/ has been modified. Please review doc/architecture.md for accuracy:
+ 1. Verify the documented architecture matches the current implementation
+ 2. Check that file paths and directory structures are still correct
+ 3. Ensure component descriptions reflect actual behavior
+ 4. Update any diagrams or flows that may have changed
diff --git a/README.md b/README.md
index 9add31f6..e8fda1f7 100644
--- a/README.md
+++ b/README.md
@@ -263,6 +263,36 @@ deepwork/
- **Namespace Isolation**: Multiple concurrent job instances supported
- **Version Control**: All outputs tracked in Git
+### Policies
+
+Policies automatically enforce team guidelines when files change:
+
+```yaml
+# .deepwork.policy.yml
+- name: "Update docs on config changes"
+ trigger: "app/config/**/*"
+ safety: "docs/install_guide.md"
+ instructions: |
+ Configuration files changed. Please update docs/install_guide.md
+ if installation instructions need to change.
+```
+
+**How it works**:
+1. When you start a Claude Code session, the baseline git state is captured
+2. When the agent finishes, changed files are compared against policy triggers
+3. If policies fire (trigger matches, no safety match), Claude is prompted to address them
+4. Use `addressed` to mark policies as handled
+
+**Use cases**:
+- Keep documentation in sync with code changes
+- Require security review for auth code modifications
+- Enforce changelog updates for API changes
+
+Define policies interactively:
+```
+/deepwork_policy.define
+```
+
## Roadmap
### Phase 2: Runtime Enhancements (Planned)
diff --git a/doc/TEMPLATE_REVIEW.md b/doc/TEMPLATE_REVIEW.md
deleted file mode 100644
index fa1d9873..00000000
--- a/doc/TEMPLATE_REVIEW.md
+++ /dev/null
@@ -1,400 +0,0 @@
-# DeepWork Template Design Review
-
-This document presents the three Jinja2 templates for review. These templates are the **core interface between DeepWork and AI agents** - they must be correct before implementation can proceed.
-
-## Overview
-
-Three templates have been designed:
-
-1. **`skill-job-step.md.jinja`** - Individual step skill (MOST CRITICAL)
-2. **`skill-deepwork.define.md.jinja`** - Job definition wizard
-3. **`skill-deepwork.refine.md.jinja`** - Job refinement tool
-
-## Review Requirements
-
-For approval, each template needs:
-- ✅ Correct Claude Code skill format
-- ✅ Clear instructions AI agents can follow
-- ✅ Proper context passing between steps
-- ✅ Good user experience
-- ✅ Handles all cases (simple jobs, complex jobs, edge cases)
-
----
-
-## Template 1: skill-job-step.md.jinja ⭐ MOST CRITICAL
-
-**Purpose**: Generates individual skill files for each step in a job workflow.
-
-**Location**: `src/deepwork/templates/claude/skill-job-step.md.jinja`
-
-### Template Features
-
-✅ **Follows Claude Code skill format**:
-- Name: job.step
-- Description
-- Sections: Overview, Instructions, Inputs, Work Branch, Outputs, Completion
-
-✅ **Handles all input types**:
-- User parameters (name + description)
-- File inputs from previous steps
-- No inputs (template adapts)
-
-✅ **Conditional sections**:
-- Prerequisites (only if dependencies exist)
-- Next step (only if not final step)
-- Workflow complete (only for final step)
-
-✅ **Clear work branch management**:
-- Consistent naming: `work/[job_name]-[instance]-[date]`
-- Instructions for checking/creating branches
-- All outputs in work directory
-
-✅ **Context preservation**:
-- Embeds step instructions from .md file
-- Lists required files with locations
-- Points to job definition and step files
-
-### Template Variables
-
-```python
-context = {
- "job_name": str, # e.g., "competitive_research"
- "job_version": str, # e.g., "1.0.0"
- "job_description": str, # e.g., "Systematic competitive analysis workflow"
-
- "step_id": str, # e.g., "primary_research"
- "step_name": str, # e.g., "Primary Research"
- "step_description": str, # e.g., "Analyze competitors' self-presentation"
- "step_number": int, # e.g., 2
- "total_steps": int, # e.g., 4
-
- "instructions_file": str, # e.g., "steps/primary_research.md"
- "instructions_content": str, # Full markdown content from the file
-
- "user_inputs": list[dict], # [{"name": "param", "description": "..."}]
- "file_inputs": list[dict], # [{"file": "data.md", "from_step": "step1"}]
- "outputs": list[str], # ["output.md", "directory/"]
- "dependencies": list[str], # ["step1", "step2"]
-
- "next_step": str | None, # "step3" or None if final
- "prev_step": str | None, # "step1" or None if first
-}
-```
-
-### Rendered Examples
-
-See full rendered examples:
-- **Example 1**: Simple job with user inputs
- - File: `doc/template_examples/example1_simple_job_user_inputs.md`
- - Shows: Single-step job, user parameters, final step handling
-
-- **Example 2**: Complex job with file inputs
- - File: `doc/template_examples/example2_complex_job_file_inputs.md`
- - Shows: Mid-workflow step, file dependencies, next step guidance
-
-- **Example 3**: Final step with multiple inputs
- - File: `doc/template_examples/example3_final_step_multiple_inputs.md`
- - Shows: Multiple file inputs, multiple dependencies, workflow completion
-
-### Key Design Decisions
-
-1. **Work branch format**: `work/[job_name]-[instance]-[date]`
- - Allows multiple concurrent instances of same job
- - Clear namespace separation
- - Git-friendly naming
-
-2. **File location references**: Always show full path `work/[branch-name]/file.md`
- - Prevents confusion about file locations
- - Makes it easy for AI to find inputs
-
-3. **Step numbering**: "Step X of Y"
- - Gives user progress context
- - Helps AI understand workflow position
-
-4. **Conditional sections**: Only show relevant information
- - No "Prerequisites" if first step
- - No "Next Step" if final step
- - Cleaner, less confusing output
-
-5. **Embedded instructions**: Full step instructions in skill file
- - AI has all context in one place
- - Reduces file reads during execution
- - Single source of truth
-
-### Potential Issues to Consider
-
-🤔 **Questions for Review**:
-
-1. **Work branch naming**: Is `work/[job_name]-[instance]-[date]` clear enough? Should we provide more guidance on what `[instance]` should be?
-
-2. **File input locations**: Do we need to validate files exist before step starts? Or trust the AI to handle missing files gracefully?
-
-3. **Instruction embedding**: Should we embed the full markdown content or just reference the file path?
-
-4. **Error handling**: What should AI do if prerequisites aren't met? Should template include fallback instructions?
-
-5. **Output directories**: How should AI handle directory outputs (ending with `/`)? Create empty dir or populate it?
-
----
-
-## Template 2: skill-deepwork.define.md.jinja
-
-**Purpose**: Interactive wizard to help users define new job workflows.
-
-**Location**: `src/deepwork/templates/claude/skill-deepwork.define.md.jinja`
-
-### Template Features
-
-✅ **Step-by-step wizard**:
-- Job metadata → Define steps → Review → Create
-
-✅ **Validation rules**:
-- Job name pattern
-- Semantic versioning
-- Dependency validation
-- No circular dependencies
-
-✅ **Helpful guidance**:
-- Tips for breaking down workflows
-- Examples of good step design
-- Clear error messages
-
-✅ **Complete file generation**:
-- job.yml with full schema
-- Step instruction files (.md)
-- Skill files for all steps
-
-### Template Structure
-
-1. **Overview**: Explains what the wizard does
-2. **Step 1**: Gather job metadata
-3. **Step 2**: Define each workflow step
-4. **Step 3**: Review and confirm
-5. **Step 4**: Create all files
-6. **Step 5**: Confirm completion
-
-### Key Design Decisions
-
-1. **Interactive approach**: Conversational wizard vs. single prompt
- - Easier for users to think through one step at a time
- - AI can validate as it goes
- - More user-friendly
-
-2. **Validation points**: Validate early and often
- - Job name immediately after input
- - Dependencies when added
- - Full graph validation at review
-
-3. **File generation**: Create everything atomically
- - All files created together
- - Reduces partial state issues
- - Clear success/failure
-
-### Template Variables
-
-This template doesn't use variables - it's static instructions for the AI agent.
-
-### Example Usage Flow
-
-```
-User: /deepwork.define
-
-AI: I'll help you define a new job! What would you like to call it?
-
-User: competitive_research
-
-AI: Great! What does this workflow accomplish?
-
-User: Systematic competitive analysis
-
-AI: Perfect! Now let's define the steps. What's the first step?
-
-[... interactive dialog continues ...]
-
-AI: ✓ Job "competitive_research" v1.0.0 has been defined!
-```
-
-### Potential Issues to Consider
-
-🤔 **Questions for Review**:
-
-1. **Complexity**: Is the wizard too complex? Should we support a "simple mode" for basic jobs?
-
-2. **Step instruction creation**: Should AI help write step instructions or require user to provide them?
-
-3. **Templates for outputs**: Should we support output templates (e.g., "Create a markdown file with these sections")?
-
-4. **Validation feedback**: Are the validation rules clear enough for users to understand what's required?
-
----
-
-## Template 3: skill-deepwork.refine.md.jinja
-
-**Purpose**: Modify existing job definitions safely.
-
-**Location**: `src/deepwork/templates/claude/skill-deepwork.refine.md.jinja`
-
-### Template Features
-
-✅ **Safe modifications**:
-- Validates impact before changes
-- Warns about breaking changes
-- Prevents invalid states
-
-✅ **Multiple operations**:
-- Add/remove steps
-- Modify instructions
-- Update inputs/outputs
-- Adjust dependencies
-- Change metadata
-
-✅ **Smart regeneration**:
-- Only regenerates affected skills
-- Updates step numbers when needed
-- Maintains consistency
-
-### Template Structure
-
-1. **Overview**: Explains refinement capabilities
-2. **Step 1**: Select job to refine
-3. **Step 2**: Choose operation
-4. **Step 3**: Make changes
-5. **Step 4**: Regenerate skills
-6. **Step 5**: Confirm changes
-
-### Key Design Decisions
-
-1. **Safety first**: Validate impact before allowing changes
- - Check if other steps depend on removed steps
- - Warn if breaking changes detected
- - Suggest fixes
-
-2. **Selective regeneration**: Only update what changed
- - Faster for small changes
- - Preserves unchanged skills
- - Clear about what was modified
-
-3. **Version guidance**: Help with semantic versioning
- - Major: Breaking changes
- - Minor: New features
- - Patch: Bug fixes
-
-### Template Variables
-
-This template doesn't use variables - it's static instructions for the AI agent.
-
-### Example Usage Flow
-
-```
-User: /deepwork.refine
-
-AI: Available jobs:
-1. competitive_research v1.0.0
-Which one?
-
-User: 1
-
-AI: What would you like to do?
-1. Add a new step
-2. Modify step instructions
-[... options ...]
-
-User: 1
-
-AI: Where should I insert the new step?
-
-[... continues ...]
-```
-
-### Potential Issues to Consider
-
-🤔 **Questions for Review**:
-
-1. **Breaking change detection**: Is the logic for detecting breaking changes comprehensive enough?
-
-2. **Rollback support**: Should we support undo/rollback if changes break the job?
-
-3. **Batch operations**: Should users be able to make multiple changes at once?
-
-4. **Diff preview**: Should we show a diff before applying changes?
-
----
-
-## Comparison with Existing Tools
-
-### vs. GitHub Spec-Kit
-
-DeepWork improves on spec-kit by:
-- ✅ **Multi-step workflows** (spec-kit is single-task)
-- ✅ **Dependency management** (spec-kit has no dependencies)
-- ✅ **Reusable job definitions** (spec-kit is one-off)
-- ✅ **Work branch organization** (spec-kit doesn't manage branches)
-
-### vs. Manual Claude Prompts
-
-DeepWork improves on manual prompts by:
-- ✅ **Consistent structure** (no ad-hoc prompting)
-- ✅ **Context preservation** (automatic file references)
-- ✅ **Progress tracking** (clear step numbers)
-- ✅ **Reproducibility** (same job definition works every time)
-
----
-
-## Testing Plan
-
-Once approved, these templates will be tested with:
-
-1. **Unit tests**: Template rendering with various contexts
-2. **Integration tests**: Full job definition → skill generation → execution
-3. **Real-world examples**:
- - Simple single-step job
- - Complex multi-step job with dependencies
- - Job with mixed input types
-
----
-
-## Approval Checklist
-
-Before proceeding with implementation:
-
-- [ ] Template 1 (skill-job-step): Approved
-- [ ] Template 2 (deepwork.define): Approved
-- [ ] Template 3 (deepwork.refine): Approved
-- [ ] Rendered examples look correct
-- [ ] No missing functionality identified
-- [ ] Design questions resolved
-
----
-
-## Next Steps After Approval
-
-1. ✅ Templates are already created in `src/deepwork/templates/claude/`
-2. Implement template renderer (Step 10)
-3. Write unit tests for template rendering
-4. Test with fixture jobs
-5. Proceed to CLI implementation (Step 11)
-
----
-
-## Files for Review
-
-**Templates**:
-- `src/deepwork/templates/claude/skill-job-step.md.jinja`
-- `src/deepwork/templates/claude/skill-deepwork.define.md.jinja`
-- `src/deepwork/templates/claude/skill-deepwork.refine.md.jinja`
-
-**Examples**:
-- `doc/template_examples/example1_simple_job_user_inputs.md`
-- `doc/template_examples/example2_complex_job_file_inputs.md`
-- `doc/template_examples/example3_final_step_multiple_inputs.md`
-
-**This Review**:
-- `doc/TEMPLATE_REVIEW.md`
-
----
-
-**Ready for review!** Please provide feedback on:
-1. Template structure and content
-2. Design decisions
-3. Any missing functionality
-4. Answers to the "Questions for Review"
diff --git a/doc/architecture.md b/doc/architecture.md
index ff04a00b..d316b513 100644
--- a/doc/architecture.md
+++ b/doc/architecture.md
@@ -44,18 +44,34 @@ deepwork/ # DeepWork tool repository
│ ├── core/
│ │ ├── detector.py # AI platform detection
│ │ ├── generator.py # Command file generation
-│ │ └── parser.py # Job definition parsing
+│ │ ├── parser.py # Job definition parsing
+│ │ ├── policy_parser.py # Policy definition parsing
+│ │ └── hooks_syncer.py # Hook syncing to platforms
+│ ├── hooks/ # Hook evaluation modules
+│ │ ├── __init__.py
+│ │ └── evaluate_policies.py # Policy evaluation CLI
│ ├── templates/ # Command templates for each platform
│ │ ├── claude/
│ │ │ └── command-job-step.md.jinja
│ │ ├── gemini/
│ │ └── copilot/
│ ├── standard_jobs/ # Built-in job definitions
-│ │ └── deepwork_jobs/
+│ │ ├── deepwork_jobs/
+│ │ │ ├── job.yml
+│ │ │ └── steps/
+│ │ └── deepwork_policy/ # Policy management job
│ │ ├── job.yml
-│ │ └── steps/
-│ ├── schemas/ # Job definition schemas
-│ │ └── job_schema.py
+│ │ ├── steps/
+│ │ │ └── define.md
+│ │ └── hooks/ # Hook scripts
+│ │ ├── global_hooks.yml
+│ │ ├── user_prompt_submit.sh
+│ │ ├── capture_work_tree.sh
+│ │ ├── get_changed_files.sh
+│ │ └── policy_stop_hook.sh
+│ ├── schemas/ # Definition schemas
+│ │ ├── job_schema.py
+│ │ └── policy_schema.py
│ └── utils/
│ ├── fs.py
│ ├── git.py
@@ -223,41 +239,40 @@ This section describes what a project looks like AFTER `deepwork install --claud
my-project/ # User's project (target)
├── .git/
├── .claude/ # Claude Code directory
+│ ├── settings.json # Includes installed hooks
│ └── commands/ # Command files
│ ├── deepwork_jobs.define.md # Core DeepWork commands
│ ├── deepwork_jobs.implement.md
│ ├── deepwork_jobs.refine.md
+│ ├── deepwork_policy.define.md # Policy management
│ ├── competitive_research.identify_competitors.md
-│ ├── competitive_research.primary_research.md
-│ ├── competitive_research.secondary_research.md
-│ ├── competitive_research.comparative_report.md
-│ └── competitive_research.positioning.md
+│ └── ...
├── .deepwork/ # DeepWork configuration
│ ├── config.yml # Platform config
+│ ├── .gitignore # Ignores .last_work_tree
│ └── jobs/ # Job definitions
│ ├── deepwork_jobs/ # Core job for managing jobs
│ │ ├── job.yml
│ │ └── steps/
-│ │ ├── define.md
-│ │ ├── implement.md
-│ │ └── refine.md
+│ ├── deepwork_policy/ # Policy management job
+│ │ ├── job.yml
+│ │ ├── steps/
+│ │ │ └── define.md
+│ │ └── hooks/ # Hook scripts (installed from standard_jobs)
+│ │ ├── global_hooks.yml
+│ │ ├── user_prompt_submit.sh
+│ │ ├── capture_work_tree.sh
+│ │ ├── get_changed_files.sh
+│ │ └── policy_stop_hook.sh
│ ├── competitive_research/
│ │ ├── job.yml # Job metadata
│ │ └── steps/
-│ │ ├── identify_competitors.md
-│ │ ├── primary_research.md
-│ │ ├── secondary_research.md
-│ │ ├── comparative_report.md
-│ │ └── positioning.md
│ └── ad_campaign/
│ └── ...
+├── .deepwork.policy.yml # Policy definitions (project root)
├── deepwork/ # Work products (Git branches)
│ ├── competitive_research-acme-2026-01-11/
-│ │ ├── competitors.md
-│ │ ├── primary_research.md
-│ │ ├── secondary_research.md
-│ │ ├── comparison_matrix.md
-│ │ └── positioning_strategy.md
+│ │ └── ...
│ └── ad_campaign-q1-2026-01-11/
│ └── ...
├── (rest of user's project files)
@@ -1206,6 +1221,113 @@ jobs:
---
+## Policies
+
+Policies are automated enforcement rules that trigger based on file changes during an AI agent session. They help ensure that:
+- Documentation stays in sync with code changes
+- Security reviews happen when sensitive code is modified
+- Team guidelines are followed automatically
+
+### Policy Configuration File
+
+Policies are defined in `.deepwork.policy.yml` at the project root:
+
+```yaml
+- name: "Update install guide on config changes"
+ trigger: "app/config/**/*"
+ safety: "docs/install_guide.md"
+ instructions: |
+ Configuration files have been modified. Please review docs/install_guide.md
+ and update it if any installation instructions need to change.
+
+- name: "Security review for auth changes"
+ trigger:
+ - "src/auth/**/*"
+ - "src/security/**/*"
+ safety:
+ - "SECURITY.md"
+ - "docs/security_audit.md"
+ instructions: |
+ Authentication or security code has been changed. Please:
+ 1. Check for hardcoded credentials
+ 2. Verify input validation
+ 3. Review access control logic
+```
+
+### Policy Evaluation Flow
+
+1. **Session Start**: When a Claude Code session begins, the baseline git state is captured
+2. **Agent Works**: The AI agent performs tasks, potentially modifying files
+3. **Session Stop**: When the agent finishes:
+ - Changed files are detected by comparing against the baseline
+ - Each policy is evaluated:
+ - If any changed file matches a `trigger` pattern AND
+ - No changed file matches a `safety` pattern AND
+ - The agent hasn't marked it with a `` tag
+ - → The policy fires
+ - If policies fire, Claude is prompted to address them
+4. **Promise Tags**: Agents can mark policies as addressed by including `addressed` in their response
+
+### Hook Integration
+
+Policies are implemented using Claude Code's hooks system. The `deepwork_policy` standard job includes:
+
+```
+.deepwork/jobs/deepwork_policy/hooks/
+├── global_hooks.yml # Maps lifecycle events to scripts
+├── user_prompt_submit.sh # Captures baseline on first prompt
+├── capture_work_tree.sh # Creates git state snapshot
+├── get_changed_files.sh # Computes changed files
+└── policy_stop_hook.sh # Evaluates policies on stop
+```
+
+The hooks are installed to `.claude/settings.json` during `deepwork sync`:
+
+```json
+{
+ "hooks": {
+ "UserPromptSubmit": [
+ {"matcher": "", "hooks": [{"type": "command", "command": ".deepwork/jobs/deepwork_policy/hooks/user_prompt_submit.sh"}]}
+ ],
+ "Stop": [
+ {"matcher": "", "hooks": [{"type": "command", "command": ".deepwork/jobs/deepwork_policy/hooks/policy_stop_hook.sh"}]}
+ ]
+ }
+}
+```
+
+### Policy Schema
+
+Policies are validated against a JSON Schema:
+
+```yaml
+- name: string # Required: Friendly name for the policy
+ trigger: string|array # Required: Glob pattern(s) for triggering files
+ safety: string|array # Optional: Glob pattern(s) for safety files
+ instructions: string # Required (unless instructions_file): What to do
+ instructions_file: string # Alternative: Path to instructions file
+```
+
+### Defining Policies
+
+Use the `/deepwork_policy.define` command to interactively create policies:
+
+```
+User: /deepwork_policy.define
+
+Claude: I'll help you define a new policy. What guideline or constraint
+ should this policy enforce?
+
+User: When API code changes, the API documentation should be updated
+
+Claude: Got it. Let me ask a few questions...
+ [Interactive dialog to define trigger, safety, and instructions]
+
+Claude: ✓ Created policy "API documentation update" in .deepwork.policy.yml
+```
+
+---
+
## Implementation Status
**Completed**: Phases 1 & 2 are complete. The core runtime, CLI, installation, and command generation systems are fully functional.
diff --git a/doc/template_examples/example1_simple_job_user_inputs.md b/doc/template_examples/example1_simple_job_user_inputs.md
deleted file mode 100644
index 3f1e1dcc..00000000
--- a/doc/template_examples/example1_simple_job_user_inputs.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Example 1: Simple Job with User Inputs
-
-**Context**: First (and only) step of simple_job - has user inputs, no dependencies
-
-**Rendered Output**:
-
----
-
-Name: simple_job.single_step
-Description: A single step that performs a task
-
-## Overview
-
-This is step 1 of 1 in the **simple_job** workflow.
-
-**Job**: A simple single-step job for testing
-
-## Instructions
-
-# Single Step Instructions
-
-## Objective
-Perform a simple task with the given input parameter.
-
-## Task
-Create an output file with the results of processing {{input_param}}.
-
-## Output Format
-Create `output.md` with the results.
-
-## Inputs
-
-### User Parameters
-
-Please gather the following information from the user:
-- **input_param**: An input parameter
-
-## Work Branch Management
-
-All work for this job should be done on a dedicated work branch:
-
-1. **Check current branch**:
- - If already on a work branch for this job (format: `work/simple_job-[instance]-[date]`), continue using it
- - If on main/master, create a new work branch
-
-2. **Create work branch** (if needed):
- ```bash
- git checkout -b work/simple_job-[instance]-$(date +%Y%m%d)
- ```
- Replace `[instance]` with a descriptive identifier (e.g., `acme`, `q1-launch`, etc.)
-
-3. **All outputs go in the work directory**:
- - Create files in: `work/[branch-name]/`
- - This keeps work products organized and reviewable
-
-## Output Requirements
-
-Create the following output(s) in the work directory:
-- `work/[branch-name]/output.md`
-
-Ensure all outputs are:
-- Well-formatted and complete
-- Committed to the work branch
-- Ready for review or use by subsequent steps
-
-## Completion
-
-After completing this step:
-
-1. **Commit your work**:
- ```bash
- git add work/[branch-name]/
- git commit -m "simple_job: Complete single_step step"
- ```
-
-2. **Verify outputs**: Confirm all required files have been created
-
-3. **Inform the user**:
- - Step 1 of 1 is complete
- - Outputs created: output.md
- - This is the final step - the job is complete!
-
-## Workflow Complete
-
-This is the final step in the simple_job workflow. All outputs should now be complete and ready for review.
-
-Consider:
-- Reviewing all work products in `work/[branch-name]/`
-- Creating a pull request to merge the work branch
-- Documenting any insights or learnings
-
----
-
-## Context Files
-
-- Job definition: `.deepwork/jobs/simple_job/job.yml`
-- Step instructions: `.deepwork/jobs/simple_job/steps/single_step.md`
diff --git a/doc/template_examples/example2_complex_job_file_inputs.md b/doc/template_examples/example2_complex_job_file_inputs.md
deleted file mode 100644
index 734c96e8..00000000
--- a/doc/template_examples/example2_complex_job_file_inputs.md
+++ /dev/null
@@ -1,103 +0,0 @@
-# Example 2: Complex Job with File Inputs and Dependencies
-
-**Context**: Step 2 of competitive_research - has file input from previous step, has dependencies
-
-**Rendered Output**:
-
----
-
-Name: competitive_research.primary_research
-Description: Analyze competitors' self-presentation
-
-## Overview
-
-This is step 2 of 4 in the **competitive_research** workflow.
-
-**Job**: Systematic competitive analysis workflow
-
-## Prerequisites
-
-This step requires completion of the following step(s):
-- `/competitive_research.identify_competitors`
-
-Please ensure these steps have been completed before proceeding.
-
-## Instructions
-
-# Primary Research
-
-## Objective
-Analyze competitors' self-presentation from their official channels.
-
-## Task
-Review each competitor and document their messaging.
-
-## Inputs
-
-### Required Files
-
-This step requires the following files from previous steps:
-- `competitors.md` (from step `identify_competitors`)
- Location: `work/[branch-name]/competitors.md`
-
-Make sure to read and use these files as context for this step.
-
-## Work Branch Management
-
-All work for this job should be done on a dedicated work branch:
-
-1. **Check current branch**:
- - If already on a work branch for this job (format: `work/competitive_research-[instance]-[date]`), continue using it
- - If on main/master, create a new work branch
-
-2. **Create work branch** (if needed):
- ```bash
- git checkout -b work/competitive_research-[instance]-$(date +%Y%m%d)
- ```
- Replace `[instance]` with a descriptive identifier (e.g., `acme`, `q1-launch`, etc.)
-
-3. **All outputs go in the work directory**:
- - Create files in: `work/[branch-name]/`
- - This keeps work products organized and reviewable
-
-## Output Requirements
-
-Create the following output(s) in the work directory:
-- `work/[branch-name]/primary_research.md`
-- `work/[branch-name]/competitor_profiles/` (directory)
-
-Ensure all outputs are:
-- Well-formatted and complete
-- Committed to the work branch
-- Ready for review or use by subsequent steps
-
-## Completion
-
-After completing this step:
-
-1. **Commit your work**:
- ```bash
- git add work/[branch-name]/
- git commit -m "competitive_research: Complete primary_research step"
- ```
-
-2. **Verify outputs**: Confirm all required files have been created
-
-3. **Inform the user**:
- - Step 2 of 4 is complete
- - Outputs created: primary_research.md, competitor_profiles/
- - Ready to proceed to next step: `/competitive_research.secondary_research`
-
-## Next Step
-
-To continue the workflow, run:
-```
-/competitive_research.secondary_research
-```
-
----
-
-## Context Files
-
-- Job definition: `.deepwork/jobs/competitive_research/job.yml`
-- Step instructions: `.deepwork/jobs/competitive_research/steps/primary_research.md`
diff --git a/doc/template_examples/example3_final_step_multiple_inputs.md b/doc/template_examples/example3_final_step_multiple_inputs.md
deleted file mode 100644
index 6932b645..00000000
--- a/doc/template_examples/example3_final_step_multiple_inputs.md
+++ /dev/null
@@ -1,112 +0,0 @@
-# Example 3: Final Step with Multiple File Inputs
-
-**Context**: Step 4 of competitive_research (final step) - has multiple file inputs, multiple dependencies
-
-**Rendered Output**:
-
----
-
-Name: competitive_research.comparative_report
-Description: Create detailed comparison matrix
-
-## Overview
-
-This is step 4 of 4 in the **competitive_research** workflow.
-
-**Job**: Systematic competitive analysis workflow
-
-## Prerequisites
-
-This step requires completion of the following step(s):
-- `/competitive_research.primary_research`
-- `/competitive_research.secondary_research`
-
-Please ensure these steps have been completed before proceeding.
-
-## Instructions
-
-# Comparative Report
-
-## Objective
-Create a comprehensive comparison matrix of all competitors.
-
-## Task
-Synthesize findings from primary and secondary research into a structured comparison.
-
-## Deliverables
-1. Comparison matrix showing feature-by-feature analysis
-2. Strengths and weaknesses document highlighting key insights
-
-## Inputs
-
-### Required Files
-
-This step requires the following files from previous steps:
-- `primary_research.md` (from step `primary_research`)
- Location: `work/[branch-name]/primary_research.md`
-- `secondary_research.md` (from step `secondary_research`)
- Location: `work/[branch-name]/secondary_research.md`
-
-Make sure to read and use these files as context for this step.
-
-## Work Branch Management
-
-All work for this job should be done on a dedicated work branch:
-
-1. **Check current branch**:
- - If already on a work branch for this job (format: `work/competitive_research-[instance]-[date]`), continue using it
- - If on main/master, create a new work branch
-
-2. **Create work branch** (if needed):
- ```bash
- git checkout -b work/competitive_research-[instance]-$(date +%Y%m%d)
- ```
- Replace `[instance]` with a descriptive identifier (e.g., `acme`, `q1-launch`, etc.)
-
-3. **All outputs go in the work directory**:
- - Create files in: `work/[branch-name]/`
- - This keeps work products organized and reviewable
-
-## Output Requirements
-
-Create the following output(s) in the work directory:
-- `work/[branch-name]/comparison_matrix.md`
-- `work/[branch-name]/strengths_weaknesses.md`
-
-Ensure all outputs are:
-- Well-formatted and complete
-- Committed to the work branch
-- Ready for review or use by subsequent steps
-
-## Completion
-
-After completing this step:
-
-1. **Commit your work**:
- ```bash
- git add work/[branch-name]/
- git commit -m "competitive_research: Complete comparative_report step"
- ```
-
-2. **Verify outputs**: Confirm all required files have been created
-
-3. **Inform the user**:
- - Step 4 of 4 is complete
- - Outputs created: comparison_matrix.md, strengths_weaknesses.md
- - This is the final step - the job is complete!
-
-## Workflow Complete
-
-This is the final step in the competitive_research workflow. All outputs should now be complete and ready for review.
-
-Consider:
-- Reviewing all work products in `work/[branch-name]/`
-- Creating a pull request to merge the work branch
-- Documenting any insights or learnings
-
----
-
-## Context Files
-
-- Job definition: `.deepwork/jobs/competitive_research/job.yml`
-- Step instructions: `.deepwork/jobs/competitive_research/steps/comparative_report.md`
diff --git a/src/deepwork/cli/install.py b/src/deepwork/cli/install.py
index 6005d4f9..d710bf8a 100644
--- a/src/deepwork/cli/install.py
+++ b/src/deepwork/cli/install.py
@@ -20,11 +20,14 @@ class InstallError(Exception):
pass
-def _inject_deepwork_jobs(jobs_dir: Path, project_path: Path) -> None:
+def _inject_standard_job(
+ job_name: str, jobs_dir: Path, project_path: Path
+) -> None:
"""
- Inject the deepwork_jobs job definition into the project.
+ Inject a standard job definition into the project.
Args:
+ job_name: Name of the standard job to inject
jobs_dir: Path to .deepwork/jobs directory
project_path: Path to project root (for relative path display)
@@ -32,16 +35,16 @@ def _inject_deepwork_jobs(jobs_dir: Path, project_path: Path) -> None:
InstallError: If injection fails
"""
# Find the standard jobs directory
- standard_jobs_dir = Path(__file__).parent.parent / "standard_jobs" / "deepwork_jobs"
+ standard_jobs_dir = Path(__file__).parent.parent / "standard_jobs" / job_name
if not standard_jobs_dir.exists():
raise InstallError(
- f"Core job definition not found at {standard_jobs_dir}. "
+ f"Standard job '{job_name}' not found at {standard_jobs_dir}. "
"DeepWork installation may be corrupted."
)
# Target directory
- target_dir = jobs_dir / "deepwork_jobs"
+ target_dir = jobs_dir / job_name
# Copy the entire directory
try:
@@ -51,11 +54,65 @@ def _inject_deepwork_jobs(jobs_dir: Path, project_path: Path) -> None:
shutil.copytree(standard_jobs_dir, target_dir)
console.print(
- f" [green]✓[/green] Installed deepwork_jobs "
+ f" [green]✓[/green] Installed {job_name} "
f"({target_dir.relative_to(project_path)})"
)
except Exception as e:
- raise InstallError(f"Failed to install core jobs: {e}") from e
+ raise InstallError(f"Failed to install {job_name}: {e}") from e
+
+
+def _inject_deepwork_jobs(jobs_dir: Path, project_path: Path) -> None:
+ """
+ Inject the deepwork_jobs job definition into the project.
+
+ Args:
+ jobs_dir: Path to .deepwork/jobs directory
+ project_path: Path to project root (for relative path display)
+
+ Raises:
+ InstallError: If injection fails
+ """
+ _inject_standard_job("deepwork_jobs", jobs_dir, project_path)
+
+
+def _inject_deepwork_policy(jobs_dir: Path, project_path: Path) -> None:
+ """
+ Inject the deepwork_policy job definition into the project.
+
+ Args:
+ jobs_dir: Path to .deepwork/jobs directory
+ project_path: Path to project root (for relative path display)
+
+ Raises:
+ InstallError: If injection fails
+ """
+ _inject_standard_job("deepwork_policy", jobs_dir, project_path)
+
+
+def _create_deepwork_gitignore(deepwork_dir: Path) -> None:
+ """
+ Create .gitignore file in .deepwork/ directory.
+
+ This ensures that temporary files like .last_work_tree are not committed.
+
+ Args:
+ deepwork_dir: Path to .deepwork directory
+ """
+ gitignore_path = deepwork_dir / ".gitignore"
+ gitignore_content = """# DeepWork temporary files
+# These files are used for policy evaluation during sessions
+.last_work_tree
+"""
+
+ # Only write if it doesn't exist or doesn't contain the entry
+ if gitignore_path.exists():
+ existing_content = gitignore_path.read_text()
+ if ".last_work_tree" not in existing_content:
+ # Append to existing
+ with open(gitignore_path, "a") as f:
+ f.write("\n" + gitignore_content)
+ else:
+ gitignore_path.write_text(gitignore_content)
@click.command()
@@ -163,9 +220,14 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
ensure_dir(jobs_dir)
console.print(f" [green]✓[/green] Created {deepwork_dir.relative_to(project_path)}/")
- # Step 3b: Inject deepwork_jobs (core job definitions)
+ # Step 3b: Inject standard jobs (core job definitions)
console.print("[yellow]→[/yellow] Installing core job definitions...")
_inject_deepwork_jobs(jobs_dir, project_path)
+ _inject_deepwork_policy(jobs_dir, project_path)
+
+ # Step 3c: Create .gitignore for temporary files
+ _create_deepwork_gitignore(deepwork_dir)
+ console.print(" [green]✓[/green] Created .deepwork/.gitignore")
# Step 4: Load or create config.yml
console.print("[yellow]→[/yellow] Updating configuration...")
diff --git a/src/deepwork/cli/sync.py b/src/deepwork/cli/sync.py
index 864ea2f7..126b9480 100644
--- a/src/deepwork/cli/sync.py
+++ b/src/deepwork/cli/sync.py
@@ -8,6 +8,7 @@
from deepwork.core.detector import PLATFORMS
from deepwork.core.generator import CommandGenerator
+from deepwork.core.hooks_syncer import collect_job_hooks, sync_hooks_to_platform
from deepwork.core.parser import parse_job_definition
from deepwork.utils.fs import ensure_dir
from deepwork.utils.yaml_utils import load_yaml
@@ -98,9 +99,14 @@ def sync_commands(project_path: Path) -> None:
except Exception as e:
console.print(f" [red]✗[/red] Failed to load {job_dir.name}: {e}")
+ # Collect hooks from all jobs
+ job_hooks_list = collect_job_hooks(jobs_dir)
+ if job_hooks_list:
+ console.print(f"[yellow]→[/yellow] Found {len(job_hooks_list)} job(s) with hooks")
+
# Sync each platform
generator = CommandGenerator()
- stats = {"platforms": 0, "commands": 0}
+ stats = {"platforms": 0, "commands": 0, "hooks": 0}
for platform_name in platforms:
if platform_name not in PLATFORMS:
@@ -127,6 +133,19 @@ def sync_commands(project_path: Path) -> None:
except Exception as e:
console.print(f" [red]✗[/red] Failed for {job.name}: {e}")
+ # Sync hooks to platform settings
+ if job_hooks_list:
+ console.print(" [dim]•[/dim] Syncing hooks...")
+ try:
+ hooks_count = sync_hooks_to_platform(
+ project_path, platform_config, job_hooks_list
+ )
+ stats["hooks"] += hooks_count
+ if hooks_count > 0:
+ console.print(f" [green]✓[/green] Synced {hooks_count} hook(s)")
+ except Exception as e:
+ console.print(f" [red]✗[/red] Failed to sync hooks: {e}")
+
stats["platforms"] += 1
# Summary
@@ -140,6 +159,8 @@ def sync_commands(project_path: Path) -> None:
table.add_row("Platforms synced", str(stats["platforms"]))
table.add_row("Total commands", str(stats["commands"]))
+ if stats["hooks"] > 0:
+ table.add_row("Hooks synced", str(stats["hooks"]))
console.print(table)
console.print()
diff --git a/src/deepwork/core/generator.py b/src/deepwork/core/generator.py
index 0956f2fa..f4bcb431 100644
--- a/src/deepwork/core/generator.py
+++ b/src/deepwork/core/generator.py
@@ -58,6 +58,30 @@ def _get_jinja_env(self, platform: PlatformConfig) -> Environment:
lstrip_blocks=True,
)
+ def _is_standalone_step(self, job: JobDefinition, step: Step) -> bool:
+ """
+ Check if a step is standalone (disconnected from the main workflow).
+
+ A standalone step has no dependencies AND no other steps depend on it.
+
+ Args:
+ job: Job definition
+ step: Step to check
+
+ Returns:
+ True if step is standalone
+ """
+ # Step has dependencies - not standalone
+ if step.dependencies:
+ return False
+
+ # Check if any other step depends on this step
+ for other_step in job.steps:
+ if step.id in other_step.dependencies:
+ return False
+
+ return True
+
def _build_step_context(
self, job: JobDefinition, step: Step, step_index: int
) -> dict[str, Any]:
@@ -92,13 +116,17 @@ def _build_step_context(
if inp.is_file_input()
]
- # Determine next and previous steps
+ # Check if this is a standalone step
+ is_standalone = self._is_standalone_step(job, step)
+
+ # Determine next and previous steps (only for non-standalone steps)
next_step = None
prev_step = None
- if step_index < len(job.steps) - 1:
- next_step = job.steps[step_index + 1].id
- if step_index > 0:
- prev_step = job.steps[step_index - 1].id
+ if not is_standalone:
+ if step_index < len(job.steps) - 1:
+ next_step = job.steps[step_index + 1].id
+ if step_index > 0:
+ prev_step = job.steps[step_index - 1].id
return {
"job_name": job.name,
@@ -118,6 +146,7 @@ def _build_step_context(
"dependencies": step.dependencies,
"next_step": next_step,
"prev_step": prev_step,
+ "is_standalone": is_standalone,
}
def generate_step_command(
diff --git a/src/deepwork/core/hooks_syncer.py b/src/deepwork/core/hooks_syncer.py
new file mode 100644
index 00000000..e11bd366
--- /dev/null
+++ b/src/deepwork/core/hooks_syncer.py
@@ -0,0 +1,267 @@
+"""Hooks syncer for DeepWork - collects and syncs hooks from jobs to platform settings."""
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from deepwork.core.detector import PlatformConfig
+from deepwork.utils.yaml_utils import load_yaml
+
+
+class HooksSyncError(Exception):
+ """Exception raised for hooks sync errors."""
+
+ pass
+
+
+@dataclass
+class HookEntry:
+ """Represents a single hook entry for a lifecycle event."""
+
+ script: str # Script filename
+ job_name: str # Job that provides this hook
+ job_dir: Path # Full path to job directory
+
+ def get_script_path(self, project_path: Path) -> str:
+ """
+ Get the script path relative to project root.
+
+ Args:
+ project_path: Path to project root
+
+ Returns:
+ Relative path to script from project root
+ """
+ # Script path is: .deepwork/jobs/{job_name}/hooks/{script}
+ script_path = self.job_dir / "hooks" / self.script
+ try:
+ return str(script_path.relative_to(project_path))
+ except ValueError:
+ # If not relative, return the full path
+ return str(script_path)
+
+
+@dataclass
+class JobHooks:
+ """Hooks configuration for a job."""
+
+ job_name: str
+ job_dir: Path
+ hooks: dict[str, list[str]] = field(default_factory=dict) # event -> [scripts]
+
+ @classmethod
+ def from_job_dir(cls, job_dir: Path) -> "JobHooks | None":
+ """
+ Load hooks configuration from a job directory.
+
+ Args:
+ job_dir: Path to job directory containing hooks/global_hooks.yml
+
+ Returns:
+ JobHooks instance or None if no hooks defined
+ """
+ hooks_file = job_dir / "hooks" / "global_hooks.yml"
+ if not hooks_file.exists():
+ return None
+
+ try:
+ with open(hooks_file, encoding="utf-8") as f:
+ data = yaml.safe_load(f)
+ except (yaml.YAMLError, OSError):
+ return None
+
+ if not data or not isinstance(data, dict):
+ return None
+
+ # Parse hooks - each key is an event, value is list of scripts
+ hooks: dict[str, list[str]] = {}
+ for event, scripts in data.items():
+ if isinstance(scripts, list):
+ hooks[event] = [str(s) for s in scripts]
+ elif isinstance(scripts, str):
+ hooks[event] = [scripts]
+
+ if not hooks:
+ return None
+
+ return cls(
+ job_name=job_dir.name,
+ job_dir=job_dir,
+ hooks=hooks,
+ )
+
+
+def collect_job_hooks(jobs_dir: Path) -> list[JobHooks]:
+ """
+ Collect hooks from all jobs in the jobs directory.
+
+ Args:
+ jobs_dir: Path to .deepwork/jobs directory
+
+ Returns:
+ List of JobHooks for all jobs with hooks defined
+ """
+ if not jobs_dir.exists():
+ return []
+
+ job_hooks_list = []
+ for job_dir in jobs_dir.iterdir():
+ if not job_dir.is_dir():
+ continue
+
+ job_hooks = JobHooks.from_job_dir(job_dir)
+ if job_hooks:
+ job_hooks_list.append(job_hooks)
+
+ return job_hooks_list
+
+
+def merge_hooks_for_platform(
+ job_hooks_list: list[JobHooks],
+ project_path: Path,
+) -> dict[str, list[dict[str, Any]]]:
+ """
+ Merge hooks from multiple jobs into a single configuration.
+
+ Args:
+ job_hooks_list: List of JobHooks from different jobs
+ project_path: Path to project root for relative path calculation
+
+ Returns:
+ Dict mapping lifecycle events to hook configurations
+ """
+ merged: dict[str, list[dict[str, Any]]] = {}
+
+ for job_hooks in job_hooks_list:
+ for event, scripts in job_hooks.hooks.items():
+ if event not in merged:
+ merged[event] = []
+
+ for script in scripts:
+ entry = HookEntry(
+ script=script,
+ job_name=job_hooks.job_name,
+ job_dir=job_hooks.job_dir,
+ )
+ script_path = entry.get_script_path(project_path)
+
+ # Create hook configuration for Claude Code format
+ hook_config = {
+ "matcher": "", # Match all
+ "hooks": [
+ {
+ "type": "command",
+ "command": script_path,
+ }
+ ],
+ }
+
+ # Check if this hook is already present (avoid duplicates)
+ if not _hook_already_present(merged[event], script_path):
+ merged[event].append(hook_config)
+
+ return merged
+
+
+def _hook_already_present(hooks: list[dict[str, Any]], script_path: str) -> bool:
+ """Check if a hook with the given script path is already in the list."""
+ for hook in hooks:
+ hook_list = hook.get("hooks", [])
+ for h in hook_list:
+ if h.get("command") == script_path:
+ return True
+ return False
+
+
+def sync_hooks_to_claude(
+ project_path: Path,
+ platform_config: PlatformConfig,
+ hooks: dict[str, list[dict[str, Any]]],
+) -> None:
+ """
+ Sync hooks to Claude Code settings.json.
+
+ Args:
+ project_path: Path to project root
+ platform_config: Platform configuration
+ hooks: Merged hooks configuration
+
+ Raises:
+ HooksSyncError: If sync fails
+ """
+ if not hooks:
+ return
+
+ settings_file = project_path / platform_config.config_dir / "settings.json"
+
+ # Load existing settings or create new
+ existing_settings: dict[str, Any] = {}
+ if settings_file.exists():
+ try:
+ with open(settings_file, encoding="utf-8") as f:
+ existing_settings = json.load(f)
+ except (json.JSONDecodeError, OSError) as e:
+ raise HooksSyncError(f"Failed to read settings.json: {e}") from e
+
+ # Merge hooks into existing settings
+ if "hooks" not in existing_settings:
+ existing_settings["hooks"] = {}
+
+ for event, event_hooks in hooks.items():
+ if event not in existing_settings["hooks"]:
+ existing_settings["hooks"][event] = []
+
+ # Add new hooks that aren't already present
+ for hook in event_hooks:
+ script_path = hook.get("hooks", [{}])[0].get("command", "")
+ if not _hook_already_present(existing_settings["hooks"][event], script_path):
+ existing_settings["hooks"][event].append(hook)
+
+ # Write back to settings.json
+ try:
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
+ with open(settings_file, "w", encoding="utf-8") as f:
+ json.dump(existing_settings, f, indent=2)
+ except OSError as e:
+ raise HooksSyncError(f"Failed to write settings.json: {e}") from e
+
+
+def sync_hooks_to_platform(
+ project_path: Path,
+ platform_config: PlatformConfig,
+ job_hooks_list: list[JobHooks],
+) -> int:
+ """
+ Sync hooks from jobs to a specific platform's settings.
+
+ Args:
+ project_path: Path to project root
+ platform_config: Platform configuration
+ job_hooks_list: List of JobHooks from jobs
+
+ Returns:
+ Number of hooks synced
+
+ Raises:
+ HooksSyncError: If sync fails
+ """
+ # Merge hooks from all jobs
+ merged_hooks = merge_hooks_for_platform(job_hooks_list, project_path)
+
+ if not merged_hooks:
+ return 0
+
+ # Currently only Claude Code is fully supported
+ if platform_config.name == "claude":
+ sync_hooks_to_claude(project_path, platform_config, merged_hooks)
+ else:
+ # For other platforms, we'd add support here
+ # For now, just skip
+ return 0
+
+ # Count total hooks
+ total = sum(len(hooks) for hooks in merged_hooks.values())
+ return total
diff --git a/src/deepwork/core/policy_parser.py b/src/deepwork/core/policy_parser.py
new file mode 100644
index 00000000..dca1d235
--- /dev/null
+++ b/src/deepwork/core/policy_parser.py
@@ -0,0 +1,285 @@
+"""Policy definition parser."""
+
+from dataclasses import dataclass, field
+from fnmatch import fnmatch
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from deepwork.schemas.policy_schema import POLICY_SCHEMA
+from deepwork.utils.validation import ValidationError, validate_against_schema
+
+
+class PolicyParseError(Exception):
+ """Exception raised for policy parsing errors."""
+
+ pass
+
+
+@dataclass
+class Policy:
+ """Represents a single policy definition."""
+
+ name: str
+ 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)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any], base_dir: Path | None = None) -> "Policy":
+ """
+ Create Policy from dictionary.
+
+ Args:
+ data: Parsed YAML data for a single policy
+ base_dir: Base directory for resolving instructions_file paths
+
+ Returns:
+ Policy instance
+
+ Raises:
+ PolicyParseError: If instructions cannot be resolved
+ """
+ # Normalize trigger to list
+ trigger = data["trigger"]
+ triggers = [trigger] if isinstance(trigger, str) else list(trigger)
+
+ # Normalize safety to list (empty if not present)
+ safety_data = data.get("safety", [])
+ safety = [safety_data] if isinstance(safety_data, str) else list(safety_data)
+
+ # Resolve instructions
+ if "instructions" in data:
+ instructions = data["instructions"]
+ elif "instructions_file" in data:
+ if base_dir is None:
+ raise PolicyParseError(
+ f"Policy '{data['name']}' uses instructions_file but no base_dir provided"
+ )
+ instructions_path = base_dir / data["instructions_file"]
+ if not instructions_path.exists():
+ raise PolicyParseError(
+ f"Policy '{data['name']}' instructions file not found: {instructions_path}"
+ )
+ try:
+ instructions = instructions_path.read_text()
+ except Exception as e:
+ raise PolicyParseError(
+ f"Policy '{data['name']}' failed to read instructions file: {e}"
+ ) from e
+ else:
+ # Schema should catch this, but be defensive
+ raise PolicyParseError(
+ f"Policy '{data['name']}' must have either 'instructions' or 'instructions_file'"
+ )
+
+ return cls(
+ name=data["name"],
+ triggers=triggers,
+ safety=safety,
+ instructions=instructions,
+ )
+
+
+def matches_pattern(file_path: str, patterns: list[str]) -> bool:
+ """
+ Check if a file path matches any of the given glob patterns.
+
+ Args:
+ file_path: File path to check (relative path)
+ patterns: List of glob patterns to match against
+
+ Returns:
+ True if the file matches any pattern
+ """
+ for pattern in patterns:
+ if _matches_glob(file_path, pattern):
+ return True
+ return False
+
+
+def _matches_glob(file_path: str, pattern: str) -> bool:
+ """
+ Match a file path against a glob pattern, supporting ** for recursive matching.
+
+ Args:
+ file_path: File path to check
+ pattern: Glob pattern (supports *, **, ?)
+
+ Returns:
+ True if matches
+ """
+ # Normalize path separators
+ file_path = file_path.replace("\\", "/")
+ pattern = pattern.replace("\\", "/")
+
+ # Handle ** patterns (recursive directory matching)
+ if "**" in pattern:
+ # Split pattern by **
+ parts = pattern.split("**")
+
+ if len(parts) == 2:
+ prefix, suffix = parts[0], parts[1]
+
+ # Remove leading/trailing slashes from suffix
+ suffix = suffix.lstrip("/")
+
+ # Check if prefix matches the start of the path
+ if prefix:
+ prefix = prefix.rstrip("/")
+ if not file_path.startswith(prefix + "/") and file_path != prefix:
+ return False
+ # Get the remaining path after prefix
+ remaining = file_path[len(prefix) :].lstrip("/")
+ else:
+ remaining = file_path
+
+ # If no suffix, any remaining path matches
+ if not suffix:
+ return True
+
+ # Check if suffix matches the end of any remaining path segment
+ # For pattern "src/**/*.py", suffix is "*.py"
+ # We need to match *.py against the filename portion
+ remaining_parts = remaining.split("/")
+ for i in range(len(remaining_parts)):
+ test_path = "/".join(remaining_parts[i:])
+ if fnmatch(test_path, suffix):
+ return True
+ # Also try just the filename
+ if fnmatch(remaining_parts[-1], suffix):
+ return True
+
+ return False
+
+ # Simple pattern without **
+ return fnmatch(file_path, pattern)
+
+
+def evaluate_policy(policy: Policy, changed_files: list[str]) -> bool:
+ """
+ Evaluate whether a policy should fire based on changed files.
+
+ A policy fires if:
+ - At least one changed file matches a trigger pattern
+ - AND no changed file matches a safety pattern
+
+ Args:
+ policy: Policy to evaluate
+ changed_files: List of changed file paths (relative)
+
+ Returns:
+ True if the policy should fire
+ """
+ # Check if any trigger matches
+ trigger_matched = False
+ for file_path in changed_files:
+ if matches_pattern(file_path, policy.triggers):
+ trigger_matched = True
+ break
+
+ if not trigger_matched:
+ return False
+
+ # Check if any safety pattern matches
+ if policy.safety:
+ for file_path in changed_files:
+ if matches_pattern(file_path, policy.safety):
+ # Safety file was also changed, don't fire
+ return False
+
+ return True
+
+
+def evaluate_policies(
+ policies: list[Policy],
+ changed_files: list[str],
+ promised_policies: set[str] | None = None,
+) -> list[Policy]:
+ """
+ Evaluate which policies should fire.
+
+ Args:
+ policies: List of policies to evaluate
+ changed_files: List of changed file paths (relative)
+ promised_policies: Set of policy names that have been marked as addressed
+ via tags (these are skipped)
+
+ Returns:
+ List of policies that should fire (trigger matches, no safety match, not promised)
+ """
+ if promised_policies is None:
+ promised_policies = set()
+
+ fired_policies = []
+ for policy in policies:
+ # Skip if already promised/addressed
+ if policy.name in promised_policies:
+ continue
+
+ if evaluate_policy(policy, changed_files):
+ fired_policies.append(policy)
+
+ return fired_policies
+
+
+def parse_policy_file(policy_path: Path | str, base_dir: Path | None = None) -> list[Policy]:
+ """
+ Parse policy definitions from a YAML file.
+
+ Args:
+ policy_path: Path to .deepwork.policy.yml file
+ base_dir: Base directory for resolving instructions_file paths.
+ Defaults to the directory containing the policy file.
+
+ Returns:
+ List of parsed Policy objects
+
+ Raises:
+ PolicyParseError: If parsing fails or validation errors occur
+ """
+ policy_path = Path(policy_path)
+
+ if not policy_path.exists():
+ raise PolicyParseError(f"Policy file does not exist: {policy_path}")
+
+ if not policy_path.is_file():
+ raise PolicyParseError(f"Policy path is not a file: {policy_path}")
+
+ # Default base_dir to policy file's directory
+ if base_dir is None:
+ base_dir = policy_path.parent
+
+ # Load YAML (policies are stored as a list, not a dict)
+ try:
+ with open(policy_path, encoding="utf-8") as f:
+ policy_data = yaml.safe_load(f)
+ except yaml.YAMLError as e:
+ raise PolicyParseError(f"Failed to parse policy YAML: {e}") from e
+ except OSError as e:
+ raise PolicyParseError(f"Failed to read policy file: {e}") from e
+
+ # Handle empty file or null content
+ if policy_data is None:
+ return []
+
+ # Validate it's a list (schema expects array)
+ if not isinstance(policy_data, list):
+ raise PolicyParseError(
+ f"Policy file must contain a list of policies, got {type(policy_data).__name__}"
+ )
+
+ # Validate against schema
+ try:
+ validate_against_schema(policy_data, POLICY_SCHEMA)
+ except ValidationError as e:
+ raise PolicyParseError(f"Policy definition validation failed: {e}") from e
+
+ # Parse into dataclasses
+ policies = []
+ for policy_item in policy_data:
+ policy = Policy.from_dict(policy_item, base_dir)
+ policies.append(policy)
+
+ return policies
diff --git a/src/deepwork/hooks/__init__.py b/src/deepwork/hooks/__init__.py
new file mode 100644
index 00000000..ed52e43a
--- /dev/null
+++ b/src/deepwork/hooks/__init__.py
@@ -0,0 +1 @@
+"""DeepWork hooks package for policy enforcement and lifecycle events."""
diff --git a/src/deepwork/hooks/evaluate_policies.py b/src/deepwork/hooks/evaluate_policies.py
new file mode 100644
index 00000000..e5af4950
--- /dev/null
+++ b/src/deepwork/hooks/evaluate_policies.py
@@ -0,0 +1,160 @@
+"""
+Policy evaluation module for DeepWork hooks.
+
+This module is called by the policy_stop_hook.sh script to evaluate which policies
+should fire based on changed files and conversation context.
+
+Usage:
+ python -m deepwork.hooks.evaluate_policies \
+ --policy-file .deepwork.policy.yml \
+ --changed-files "file1.py\nfile2.py"
+
+The conversation context is read from stdin and checked for tags
+that indicate policies have already been addressed.
+
+Output is JSON suitable for Claude Code Stop hooks:
+ {"decision": "block", "reason": "..."} # Block stop, policies need attention
+ {} # No policies fired, allow stop
+"""
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+from deepwork.core.policy_parser import (
+ PolicyParseError,
+ evaluate_policies,
+ parse_policy_file,
+)
+
+
+def extract_promise_tags(text: str) -> set[str]:
+ """
+ Extract policy names from tags in text.
+
+ Supported formats:
+ - ...
+ - ...
+
+ Args:
+ text: Text to search for promise tags
+
+ Returns:
+ Set of policy names that have been promised/addressed
+ """
+ # Match ... or
+ pattern = r'.*?'
+ matches = re.findall(pattern, text, re.IGNORECASE | re.DOTALL)
+ return set(matches)
+
+
+def format_policy_message(policies: list) -> str:
+ """
+ Format triggered policies into a message for the agent.
+
+ Args:
+ policies: List of Policy objects that fired
+
+ Returns:
+ Formatted message with all policy instructions
+ """
+ lines = ["## DeepWork Policies Triggered", ""]
+ lines.append(
+ "Comply with the following policies. "
+ "To mark a policy as addressed, include `addressed` "
+ "in your response."
+ )
+ lines.append("")
+
+ for policy in policies:
+ lines.append(f"### Policy: {policy.name}")
+ lines.append("")
+ lines.append(policy.instructions.strip())
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def main() -> None:
+ """Main entry point for policy evaluation CLI."""
+ parser = argparse.ArgumentParser(
+ description="Evaluate DeepWork policies based on changed files"
+ )
+ parser.add_argument(
+ "--policy-file",
+ type=str,
+ 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():
+ # No policy file, nothing to evaluate
+ print("{}")
+ return
+
+ # Read conversation context from stdin (if available)
+ conversation_context = ""
+ if not sys.stdin.isatty():
+ try:
+ conversation_context = sys.stdin.read()
+ except Exception:
+ pass
+
+ # Extract promise tags from conversation
+ promised_policies = extract_promise_tags(conversation_context)
+
+ # Parse and evaluate policies
+ try:
+ policies = parse_policy_file(policy_path)
+ except PolicyParseError as e:
+ # Log error to stderr, return empty result
+ print(f"Error parsing policy file: {e}", file=sys.stderr)
+ print("{}")
+ return
+
+ if not policies:
+ # No policies defined
+ print("{}")
+ return
+
+ # Evaluate which policies fire
+ fired_policies = evaluate_policies(policies, changed_files, promised_policies)
+
+ if not fired_policies:
+ # No policies fired
+ print("{}")
+ return
+
+ # Format output for Claude Code Stop hooks
+ # Use "decision": "block" to prevent Claude from stopping
+ message = format_policy_message(fired_policies)
+ result = {
+ "decision": "block",
+ "reason": message,
+ }
+
+ print(json.dumps(result))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/deepwork/schemas/policy_schema.py b/src/deepwork/schemas/policy_schema.py
new file mode 100644
index 00000000..7c12ac28
--- /dev/null
+++ b/src/deepwork/schemas/policy_schema.py
@@ -0,0 +1,68 @@
+"""JSON Schema definition for policy definitions."""
+
+from typing import Any
+
+# JSON Schema for .deepwork.policy.yml files
+# Policies are defined as an array of policy objects
+POLICY_SCHEMA: dict[str, Any] = {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "array",
+ "description": "List of policies that trigger based on file changes",
+ "items": {
+ "type": "object",
+ "required": ["name", "trigger"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Friendly name for the policy",
+ },
+ "trigger": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Glob pattern for files that trigger this policy",
+ },
+ {
+ "type": "array",
+ "items": {"type": "string", "minLength": 1},
+ "minItems": 1,
+ "description": "List of glob patterns for files that trigger this policy",
+ },
+ ],
+ "description": "Glob pattern(s) for files that, if changed, should trigger this policy",
+ },
+ "safety": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Glob pattern for safety files",
+ },
+ {
+ "type": "array",
+ "items": {"type": "string", "minLength": 1},
+ "description": "List of glob patterns for safety files",
+ },
+ ],
+ "description": "Glob pattern(s) for files that, if also changed, mean the policy doesn't need to trigger",
+ },
+ "instructions": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Instructions to give the agent when this policy triggers",
+ },
+ "instructions_file": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Path to a file containing instructions (alternative to inline instructions)",
+ },
+ },
+ "oneOf": [
+ {"required": ["instructions"]},
+ {"required": ["instructions_file"]},
+ ],
+ "additionalProperties": False,
+ },
+}
diff --git a/src/deepwork/standard_jobs/deepwork_policy/hooks/capture_work_tree.sh b/src/deepwork/standard_jobs/deepwork_policy/hooks/capture_work_tree.sh
new file mode 100755
index 00000000..04d9a972
--- /dev/null
+++ b/src/deepwork/standard_jobs/deepwork_policy/hooks/capture_work_tree.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+# capture_work_tree.sh - Captures the current git work tree state
+#
+# 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.
+
+set -e
+
+# Ensure .deepwork directory exists
+mkdir -p .deepwork
+
+# Stage all changes so we can diff against HEAD
+git add -A 2>/dev/null || true
+
+# Save the current state of changed files
+# Using git diff --name-only HEAD to get the list of all changed files
+git diff --name-only HEAD > .deepwork/.last_work_tree 2>/dev/null || true
+
+# Also include untracked files not yet in the index
+git ls-files --others --exclude-standard >> .deepwork/.last_work_tree 2>/dev/null || true
+
+# Sort and deduplicate
+if [ -f .deepwork/.last_work_tree ]; then
+ sort -u .deepwork/.last_work_tree -o .deepwork/.last_work_tree
+fi
diff --git a/src/deepwork/standard_jobs/deepwork_policy/hooks/get_changed_files.sh b/src/deepwork/standard_jobs/deepwork_policy/hooks/get_changed_files.sh
new file mode 100755
index 00000000..03f70d9e
--- /dev/null
+++ b/src/deepwork/standard_jobs/deepwork_policy/hooks/get_changed_files.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+# get_changed_files.sh - Gets files that changed since the last work tree capture
+#
+# This script compares the current git state against the baseline captured
+# at the start of the session to determine what files were modified.
+
+set -e
+
+# Stage all current changes
+git add -A 2>/dev/null || true
+
+# Get current state
+current_files=$(git diff --name-only HEAD 2>/dev/null || echo "")
+untracked=$(git ls-files --others --exclude-standard 2>/dev/null || echo "")
+
+# Combine and deduplicate current files
+all_current=$(echo -e "${current_files}\n${untracked}" | sort -u | grep -v '^$' || true)
+
+if [ -f .deepwork/.last_work_tree ]; then
+ # Compare with baseline - files that are new or different
+ # Get files in current that weren't in baseline
+ last_files=$(cat .deepwork/.last_work_tree 2>/dev/null || echo "")
+
+ # Output files that are in current state
+ # This includes both newly changed files and files that were already changed
+ echo "${all_current}"
+else
+ # No baseline exists - return all currently changed files
+ echo "${all_current}"
+fi
diff --git a/src/deepwork/standard_jobs/deepwork_policy/hooks/global_hooks.yml b/src/deepwork/standard_jobs/deepwork_policy/hooks/global_hooks.yml
new file mode 100644
index 00000000..0e024fc7
--- /dev/null
+++ b/src/deepwork/standard_jobs/deepwork_policy/hooks/global_hooks.yml
@@ -0,0 +1,8 @@
+# DeepWork Policy Hooks Configuration
+# Maps Claude Code lifecycle events to hook scripts
+
+UserPromptSubmit:
+ - user_prompt_submit.sh
+
+Stop:
+ - policy_stop_hook.sh
diff --git a/src/deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh b/src/deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh
new file mode 100755
index 00000000..6d598a3e
--- /dev/null
+++ b/src/deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+# policy_stop_hook.sh - Evaluates policies when the agent stops
+#
+# This script is called as a Claude Code Stop hook. It:
+# 1. Gets the list of files changed during the session
+# 2. Evaluates policies from .deepwork.policy.yml
+# 3. Checks for tags in the conversation transcript
+# 4. Returns JSON to block stop if policies need attention
+# 5. Resets the work tree baseline for the next iteration
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Check if policy file exists
+if [ ! -f .deepwork.policy.yml ]; then
+ # No policies defined, nothing to do
+ exit 0
+fi
+
+# Read the hook input JSON from stdin
+HOOK_INPUT=""
+if [ ! -t 0 ]; then
+ HOOK_INPUT=$(cat)
+fi
+
+# Extract transcript_path from the hook input JSON using jq
+# Claude Code passes: {"session_id": "...", "transcript_path": "...", ...}
+TRANSCRIPT_PATH=""
+if [ -n "${HOOK_INPUT}" ]; then
+ TRANSCRIPT_PATH=$(echo "${HOOK_INPUT}" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
+fi
+
+# Get changed files
+changed_files=$("${SCRIPT_DIR}/get_changed_files.sh" 2>/dev/null || echo "")
+
+# If no files changed, nothing to evaluate
+if [ -z "${changed_files}" ]; then
+ # Reset baseline for next iteration
+ "${SCRIPT_DIR}/capture_work_tree.sh" 2>/dev/null || true
+ exit 0
+fi
+
+# Extract conversation text from the JSONL transcript
+# The transcript is JSONL format - each line is a JSON object
+# We need to extract the text content from assistant messages
+conversation_context=""
+if [ -n "${TRANSCRIPT_PATH}" ] && [ -f "${TRANSCRIPT_PATH}" ]; then
+ # Extract text content from all assistant messages in the transcript
+ # Each line is a JSON object; we extract .message.content[].text for assistant messages
+ conversation_context=$(cat "${TRANSCRIPT_PATH}" | \
+ grep -E '"role"\s*:\s*"assistant"' | \
+ jq -r '.message.content // [] | map(select(.type == "text")) | map(.text) | join("\n")' 2>/dev/null | \
+ tr -d '\0' || echo "")
+fi
+
+# Call the Python evaluator
+# The Python module handles:
+# - Parsing the policy file
+# - Matching changed files against triggers/safety patterns
+# - Checking for promise tags in the conversation context
+# - Generating appropriate JSON output
+result=$(echo "${conversation_context}" | python -m deepwork.hooks.evaluate_policies \
+ --policy-file .deepwork.policy.yml \
+ --changed-files "${changed_files}" \
+ 2>/dev/null || echo '{}')
+
+# Reset the work tree baseline for the next iteration
+"${SCRIPT_DIR}/capture_work_tree.sh" 2>/dev/null || true
+
+# Output the result (JSON for Claude Code hooks)
+echo "${result}"
diff --git a/src/deepwork/standard_jobs/deepwork_policy/hooks/user_prompt_submit.sh b/src/deepwork/standard_jobs/deepwork_policy/hooks/user_prompt_submit.sh
new file mode 100755
index 00000000..970be76c
--- /dev/null
+++ b/src/deepwork/standard_jobs/deepwork_policy/hooks/user_prompt_submit.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+# user_prompt_submit.sh - Runs on every user prompt submission
+#
+# This script captures the work tree baseline if it doesn't exist yet.
+# This ensures we have a baseline to compare against when evaluating policies.
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Only capture if no baseline exists yet (first prompt of session)
+if [ ! -f .deepwork/.last_work_tree ]; then
+ "${SCRIPT_DIR}/capture_work_tree.sh"
+fi
+
+# Exit successfully - don't block the prompt
+exit 0
diff --git a/src/deepwork/standard_jobs/deepwork_policy/job.yml b/src/deepwork/standard_jobs/deepwork_policy/job.yml
new file mode 100644
index 00000000..40adce89
--- /dev/null
+++ b/src/deepwork/standard_jobs/deepwork_policy/job.yml
@@ -0,0 +1,35 @@
+name: deepwork_policy
+version: "1.0.0"
+summary: "Policy enforcement for AI agent sessions"
+description: |
+ Manages policies that automatically trigger when certain files change during an AI agent session.
+ Policies help ensure that code changes follow team guidelines, documentation is updated,
+ and architectural decisions are respected.
+
+ Policies are defined in a `.deepwork.policy.yml` file at the root of your project. Each policy
+ specifies:
+ - Trigger patterns: Glob patterns for files that, when changed, should trigger the policy
+ - Safety patterns: Glob patterns for files that, if also changed, mean the policy doesn't need to fire
+ - Instructions: What the agent should do when the policy triggers
+
+ Example use cases:
+ - Update installation docs when configuration files change
+ - Require security review when authentication code is modified
+ - Ensure API documentation stays in sync with API code
+ - Remind developers to update changelogs
+
+changelog:
+ - version: "1.0.0"
+ changes: "Initial policy system implementation"
+
+steps:
+ - id: define
+ name: "Define Policy"
+ description: "Create or update policy entries in .deepwork.policy.yml"
+ instructions_file: steps/define.md
+ inputs:
+ - name: policy_purpose
+ description: "What guideline or constraint should this policy enforce?"
+ outputs:
+ - .deepwork.policy.yml
+ dependencies: []
diff --git a/src/deepwork/standard_jobs/deepwork_policy/steps/define.md b/src/deepwork/standard_jobs/deepwork_policy/steps/define.md
new file mode 100644
index 00000000..b2114bef
--- /dev/null
+++ b/src/deepwork/standard_jobs/deepwork_policy/steps/define.md
@@ -0,0 +1,174 @@
+# Define Policy
+
+## Objective
+
+Create or update policy entries in the `.deepwork.policy.yml` file to enforce team guidelines, documentation requirements, or other constraints when specific files change.
+
+## Task
+
+Guide the user through defining a new policy by asking clarifying questions. **Do not create the policy without first understanding what they want to enforce.**
+
+### Step 1: Understand the Policy Purpose
+
+Start by asking questions to understand what the user wants to enforce:
+
+1. **What guideline or constraint should this policy enforce?**
+ - What situation triggers the need for action?
+ - What files or directories, when changed, should trigger this policy?
+ - Examples: "When config files change", "When API code changes", "When database schema changes"
+
+2. **What action should be taken?**
+ - What should the agent do when the policy triggers?
+ - Update documentation? Perform a security review? Update tests?
+ - Is there a specific file or process that needs attention?
+
+3. **Are there any "safety" conditions?**
+ - Are there files that, if also changed, mean the policy doesn't need to fire?
+ - For example: If config changes AND install_guide.md changes, assume docs are already updated
+ - This prevents redundant prompts when the user has already done the right thing
+
+### Step 2: Define the Trigger Patterns
+
+Help the user define glob patterns for files that should trigger the policy:
+
+**Common patterns:**
+- `src/**/*.py` - All Python files in src directory (recursive)
+- `app/config/**/*` - All files in app/config directory
+- `*.md` - All markdown files in root
+- `src/api/**/*` - All files in the API directory
+- `migrations/**/*.sql` - All SQL migrations
+
+**Pattern syntax:**
+- `*` - Matches any characters within a single path segment
+- `**` - Matches any characters across multiple path segments (recursive)
+- `?` - Matches a single character
+
+### Step 3: Define Safety Patterns (Optional)
+
+If there are files that, when also changed, mean the policy shouldn't fire:
+
+**Examples:**
+- Policy: "Update install guide when config changes"
+ - Trigger: `app/config/**/*`
+ - Safety: `docs/install_guide.md` (if already updated, don't prompt)
+
+- Policy: "Security review for auth changes"
+ - Trigger: `src/auth/**/*`
+ - Safety: `SECURITY.md`, `docs/security_review.md`
+
+### Step 4: Write the Instructions
+
+Create clear, actionable instructions for what the agent should do when the policy fires.
+
+**Good instructions include:**
+- What to check or review
+- What files might need updating
+- Specific actions to take
+- Quality criteria for completion
+
+**Example:**
+```
+Configuration files have changed. Please:
+1. Review docs/install_guide.md for accuracy
+2. Update any installation steps that reference changed config
+3. Verify environment variable documentation is current
+4. Test that installation instructions still work
+```
+
+### Step 5: Create the Policy Entry
+
+Create or update `.deepwork.policy.yml` in the project root.
+
+**File Location**: `.deepwork.policy.yml` (root of project)
+
+**Format**:
+```yaml
+- name: "[Friendly name for the policy]"
+ trigger: "[glob pattern]" # or array: ["pattern1", "pattern2"]
+ safety: "[glob pattern]" # optional, or array
+ instructions: |
+ [Multi-line instructions for the agent...]
+```
+
+**Alternative with instructions_file**:
+```yaml
+- name: "[Friendly name for the policy]"
+ trigger: "[glob pattern]"
+ safety: "[glob pattern]"
+ instructions_file: "path/to/instructions.md"
+```
+
+### Step 6: Verify the Policy
+
+After creating the policy:
+
+1. **Check the YAML syntax** - Ensure valid YAML formatting
+2. **Test trigger patterns** - Verify patterns match intended files
+3. **Review instructions** - Ensure they're clear and actionable
+4. **Check for conflicts** - Ensure the policy doesn't conflict with existing ones
+
+## Example Policies
+
+### Update Documentation on Config Changes
+```yaml
+- name: "Update install guide on config changes"
+ trigger: "app/config/**/*"
+ safety: "docs/install_guide.md"
+ instructions: |
+ Configuration files have been modified. Please review docs/install_guide.md
+ and update it if any installation instructions need to change based on the
+ new configuration.
+```
+
+### Security Review for Auth Code
+```yaml
+- name: "Security review for authentication changes"
+ trigger:
+ - "src/auth/**/*"
+ - "src/security/**/*"
+ safety:
+ - "SECURITY.md"
+ - "docs/security_audit.md"
+ instructions: |
+ Authentication or security code has been changed. Please:
+ 1. Review for hardcoded credentials or secrets
+ 2. Check input validation on user inputs
+ 3. Verify access control logic is correct
+ 4. Update security documentation if needed
+```
+
+### API Documentation Sync
+```yaml
+- name: "API documentation update"
+ trigger: "src/api/**/*.py"
+ safety: "docs/api/**/*.md"
+ instructions: |
+ API code has changed. Please verify that API documentation in docs/api/
+ is up to date with the code changes. Pay special attention to:
+ - New or changed endpoints
+ - Modified request/response schemas
+ - Updated authentication requirements
+```
+
+## Output Format
+
+### .deepwork.policy.yml
+Create or update this file at the project root with the new policy entry.
+
+## Quality Criteria
+
+- Policy name is clear and descriptive
+- Trigger patterns accurately match the intended files
+- Safety patterns prevent unnecessary triggering
+- Instructions are actionable and specific
+- YAML is valid and properly formatted
+
+## Context
+
+Policies are evaluated automatically when you finish working on a task. The system:
+1. Tracks which files you changed during the session
+2. Checks if any changes match policy trigger patterns
+3. Skips policies where safety patterns also matched
+4. Prompts you with instructions for any triggered policies
+
+You can mark a policy as addressed by including `considered` in your response. This tells the system you've already handled that policy's requirements.
diff --git a/src/deepwork/templates/claude/command-job-step.md.jinja b/src/deepwork/templates/claude/command-job-step.md.jinja
index fe371b90..b1c8d887 100644
--- a/src/deepwork/templates/claude/command-job-step.md.jinja
+++ b/src/deepwork/templates/claude/command-job-step.md.jinja
@@ -4,7 +4,11 @@ description: {{ step_description }}
# {{ job_name }}.{{ step_id }}
+{% if is_standalone %}
+**Standalone command** in the **{{ job_name }}** job - can be run anytime
+{% else %}
**Step {{ step_number }} of {{ total_steps }}** in the **{{ job_name }}** workflow
+{% endif %}
**Summary**: {{ job_summary }}
@@ -97,6 +101,11 @@ After completing this step:
2. **Verify outputs**: Confirm all required files have been created
3. **Inform the user**:
+{% if is_standalone %}
+ - The {{ step_id }} command is complete
+ - Outputs created: {{ outputs | join(', ') }}
+ - This command can be run again anytime to make further changes
+{% else %}
- Step {{ step_number }} of {{ total_steps }} is complete
- Outputs created: {{ outputs | join(', ') }}
{% if next_step %}
@@ -104,8 +113,18 @@ After completing this step:
{% else %}
- This is the final step - the job is complete!
{% endif %}
+{% endif %}
+
+{% if is_standalone %}
+## Command Complete
-{% if next_step %}
+This is a standalone command that can be run anytime. The outputs are ready for use.
+
+Consider:
+- Reviewing the outputs in `deepwork/{{ job_name }}/`
+- Running `deepwork sync` if job definitions were changed
+- Re-running this command later if further changes are needed
+{% elif next_step %}
## Next Step
To continue the workflow, run:
diff --git a/tests/fixtures/policies/empty_policy.yml b/tests/fixtures/policies/empty_policy.yml
new file mode 100644
index 00000000..c8faa07a
--- /dev/null
+++ b/tests/fixtures/policies/empty_policy.yml
@@ -0,0 +1 @@
+# Empty policy file
diff --git a/tests/fixtures/policies/instructions/security_review.md b/tests/fixtures/policies/instructions/security_review.md
new file mode 100644
index 00000000..b64978bc
--- /dev/null
+++ b/tests/fixtures/policies/instructions/security_review.md
@@ -0,0 +1,8 @@
+## Security Review Required
+
+Authentication code has been modified. Please:
+
+1. Check for hardcoded credentials
+2. Verify input validation
+3. Review access control logic
+4. Update security documentation
diff --git a/tests/fixtures/policies/invalid_missing_instructions.yml b/tests/fixtures/policies/invalid_missing_instructions.yml
new file mode 100644
index 00000000..6c47934a
--- /dev/null
+++ b/tests/fixtures/policies/invalid_missing_instructions.yml
@@ -0,0 +1,2 @@
+- name: "Invalid policy"
+ trigger: "src/**/*"
diff --git a/tests/fixtures/policies/invalid_missing_trigger.yml b/tests/fixtures/policies/invalid_missing_trigger.yml
new file mode 100644
index 00000000..a5c89493
--- /dev/null
+++ b/tests/fixtures/policies/invalid_missing_trigger.yml
@@ -0,0 +1,3 @@
+- name: "Invalid policy"
+ safety: "some/file.md"
+ instructions: "This policy is missing a trigger"
diff --git a/tests/fixtures/policies/multiple_policies.yml b/tests/fixtures/policies/multiple_policies.yml
new file mode 100644
index 00000000..da292317
--- /dev/null
+++ b/tests/fixtures/policies/multiple_policies.yml
@@ -0,0 +1,21 @@
+- name: "Update install guide on config changes"
+ trigger: "app/config/**/*"
+ safety: "docs/install_guide.md"
+ instructions: "Update docs/install_guide.md if needed."
+
+- name: "Security review for auth changes"
+ trigger:
+ - "src/auth/**/*"
+ - "src/security/**/*"
+ safety:
+ - "SECURITY.md"
+ - "docs/security_review.md"
+ instructions: |
+ Authentication or security code has changed.
+ Please ensure:
+ 1. No secrets are exposed
+ 2. Security review documentation is updated
+
+- name: "API documentation update"
+ trigger: "src/api/**/*.py"
+ instructions: "API code changed. Update API documentation."
diff --git a/tests/fixtures/policies/policy_with_instructions_file.yml b/tests/fixtures/policies/policy_with_instructions_file.yml
new file mode 100644
index 00000000..267bfc66
--- /dev/null
+++ b/tests/fixtures/policies/policy_with_instructions_file.yml
@@ -0,0 +1,3 @@
+- name: "Security review"
+ trigger: "src/auth/**/*"
+ instructions_file: "instructions/security_review.md"
diff --git a/tests/fixtures/policies/valid_policy.yml b/tests/fixtures/policies/valid_policy.yml
new file mode 100644
index 00000000..a2b0b6be
--- /dev/null
+++ b/tests/fixtures/policies/valid_policy.yml
@@ -0,0 +1,6 @@
+- name: "Update install guide on config changes"
+ trigger: "app/config/**/*"
+ safety: "docs/install_guide.md"
+ instructions: |
+ Configuration files have changed. Please review docs/install_guide.md
+ and update it if the installation instructions need to change.
diff --git a/tests/integration/test_full_workflow.py b/tests/integration/test_full_workflow.py
index d01af079..ab19e885 100644
--- a/tests/integration/test_full_workflow.py
+++ b/tests/integration/test_full_workflow.py
@@ -3,7 +3,7 @@
from pathlib import Path
from deepwork.core.detector import PLATFORMS
-from deepwork.core.generator import SkillGenerator
+from deepwork.core.generator import CommandGenerator
from deepwork.core.parser import parse_job_definition
@@ -22,7 +22,7 @@ def test_parse_and_generate_workflow(
assert len(job.steps) == 4
# Step 2: Generate skills
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skills_dir = temp_dir / ".claude"
skills_dir.mkdir()
@@ -51,7 +51,7 @@ def test_simple_job_workflow(self, fixtures_dir: Path, temp_dir: Path) -> None:
assert len(job.steps) == 1
# Generate
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skills_dir = temp_dir / ".claude"
skills_dir.mkdir()
@@ -74,7 +74,7 @@ def test_skill_generation_with_dependencies(
job_dir = fixtures_dir / "jobs" / "complex_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skills_dir = temp_dir / ".claude"
skills_dir.mkdir()
@@ -105,7 +105,7 @@ def test_skill_generation_with_file_inputs(
job_dir = fixtures_dir / "jobs" / "complex_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skills_dir = temp_dir / ".claude"
skills_dir.mkdir()
@@ -131,7 +131,7 @@ def test_skill_generation_with_user_inputs(
job_dir = fixtures_dir / "jobs" / "complex_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skills_dir = temp_dir / ".claude"
skills_dir.mkdir()
@@ -147,7 +147,7 @@ def test_skill_generation_with_user_inputs(
def test_core_skills_generation(self, temp_dir: Path) -> None:
"""Test generation of core DeepWork skills."""
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skills_dir = temp_dir / ".claude"
skills_dir.mkdir()
diff --git a/tests/unit/test_evaluate_policies.py b/tests/unit/test_evaluate_policies.py
new file mode 100644
index 00000000..dfda87fd
--- /dev/null
+++ b/tests/unit/test_evaluate_policies.py
@@ -0,0 +1,113 @@
+"""Tests for the hooks evaluate_policies module."""
+
+import pytest
+
+from deepwork.hooks.evaluate_policies import extract_promise_tags, format_policy_message
+from deepwork.core.policy_parser import Policy
+
+
+class TestExtractPromiseTags:
+ """Tests for extract_promise_tags function."""
+
+ def test_extracts_double_quote_promise(self) -> None:
+ """Test extracting promise with double quotes."""
+ text = 'addressed'
+ result = extract_promise_tags(text)
+ assert result == {"Update Docs"}
+
+ def test_extracts_single_quote_promise(self) -> None:
+ """Test extracting promise with single quotes."""
+ text = "done"
+ result = extract_promise_tags(text)
+ assert result == {"Security Review"}
+
+ def test_extracts_multiple_promises(self) -> None:
+ """Test extracting multiple promise tags."""
+ text = """
+ I've addressed the policies.
+ done
+ checked
+ """
+ result = extract_promise_tags(text)
+ assert result == {"Update Docs", "Security Review"}
+
+ def test_case_insensitive(self) -> None:
+ """Test that promise tag matching is case insensitive."""
+ text = 'done'
+ result = extract_promise_tags(text)
+ assert result == {"Test Policy"}
+
+ def test_returns_empty_set_for_no_promises(self) -> None:
+ """Test that empty set is returned when no promises found."""
+ text = "This is just some regular text without any promise tags."
+ result = extract_promise_tags(text)
+ assert result == set()
+
+ def test_multiline_promise_content(self) -> None:
+ """Test promise tag with multiline content."""
+ text = '''
+ I have addressed this by:
+ 1. Updating the docs
+ 2. Running tests
+ '''
+ result = extract_promise_tags(text)
+ assert result == {"Complex Policy"}
+
+
+class TestFormatPolicyMessage:
+ """Tests for format_policy_message function."""
+
+ def test_formats_single_policy(self) -> None:
+ """Test formatting a single policy."""
+ policies = [
+ Policy(
+ name="Test Policy",
+ triggers=["src/*"],
+ safety=[],
+ instructions="Please update the documentation.",
+ )
+ ]
+ result = format_policy_message(policies)
+
+ assert "## DeepWork Policies Triggered" in result
+ assert "### Policy: Test Policy" in result
+ assert "Please update the documentation." in result
+ assert '' in result
+
+ def test_formats_multiple_policies(self) -> None:
+ """Test formatting multiple policies."""
+ policies = [
+ Policy(
+ name="Policy 1",
+ triggers=["src/*"],
+ safety=[],
+ instructions="Do thing 1.",
+ ),
+ Policy(
+ name="Policy 2",
+ triggers=["test/*"],
+ safety=[],
+ instructions="Do thing 2.",
+ ),
+ ]
+ result = format_policy_message(policies)
+
+ assert "### Policy: Policy 1" in result
+ assert "### Policy: Policy 2" in result
+ assert "Do thing 1." in result
+ assert "Do thing 2." in result
+
+ def test_strips_instruction_whitespace(self) -> None:
+ """Test that instruction whitespace is stripped."""
+ policies = [
+ Policy(
+ name="Test",
+ triggers=["*"],
+ safety=[],
+ instructions=" \n Instructions here \n ",
+ )
+ ]
+ result = format_policy_message(policies)
+
+ # Should be stripped but present
+ assert "Instructions here" in result
diff --git a/tests/unit/test_generator.py b/tests/unit/test_generator.py
index 2716c0fc..aa19fce9 100644
--- a/tests/unit/test_generator.py
+++ b/tests/unit/test_generator.py
@@ -5,16 +5,16 @@
import pytest
from deepwork.core.detector import PLATFORMS
-from deepwork.core.generator import GeneratorError, SkillGenerator
+from deepwork.core.generator import GeneratorError, CommandGenerator
from deepwork.core.parser import parse_job_definition
-class TestSkillGenerator:
- """Tests for SkillGenerator class."""
+class TestCommandGenerator:
+ """Tests for CommandGenerator class."""
def test_init_default_templates_dir(self) -> None:
"""Test initialization with default templates directory."""
- generator = SkillGenerator()
+ generator = CommandGenerator()
assert generator.templates_dir.exists()
assert (generator.templates_dir / "claude").exists()
@@ -24,7 +24,7 @@ def test_init_custom_templates_dir(self, temp_dir: Path) -> None:
templates_dir = temp_dir / "templates"
templates_dir.mkdir()
- generator = SkillGenerator(templates_dir)
+ generator = CommandGenerator(templates_dir)
assert generator.templates_dir == templates_dir
@@ -33,7 +33,7 @@ def test_init_raises_for_missing_templates_dir(self, temp_dir: Path) -> None:
nonexistent = temp_dir / "nonexistent"
with pytest.raises(GeneratorError, match="Templates directory not found"):
- SkillGenerator(nonexistent)
+ CommandGenerator(nonexistent)
def test_generate_step_skill_simple_job(
self, fixtures_dir: Path, temp_dir: Path
@@ -42,7 +42,7 @@ def test_generate_step_skill_simple_job(
job_dir = fixtures_dir / "jobs" / "simple_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skill_path = generator.generate_step_skill(
@@ -65,7 +65,7 @@ def test_generate_step_skill_complex_job_first_step(
job_dir = fixtures_dir / "jobs" / "complex_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skill_path = generator.generate_step_skill(
@@ -89,7 +89,7 @@ def test_generate_step_skill_complex_job_middle_step(
job_dir = fixtures_dir / "jobs" / "complex_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
# Generate primary_research (step 2)
@@ -116,7 +116,7 @@ def test_generate_step_skill_complex_job_final_step(
job_dir = fixtures_dir / "jobs" / "complex_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
# Generate comparative_report (step 4)
@@ -143,7 +143,7 @@ def test_generate_step_skill_raises_for_missing_step(
job_dir = fixtures_dir / "jobs" / "simple_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
# Create a fake step not in the job
@@ -175,7 +175,7 @@ def test_generate_step_skill_raises_for_missing_instructions(
# Delete the instructions file
instructions_file.unlink()
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
with pytest.raises(GeneratorError, match="instructions file not found"):
@@ -189,7 +189,7 @@ def test_generate_all_skills(self, fixtures_dir: Path, temp_dir: Path) -> None:
job_dir = fixtures_dir / "jobs" / "complex_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skill_paths = generator.generate_all_skills(job, platform, temp_dir)
@@ -209,7 +209,7 @@ def test_generate_all_skills(self, fixtures_dir: Path, temp_dir: Path) -> None:
def test_generate_core_skills(self, temp_dir: Path) -> None:
"""Test generating core DeepWork skills."""
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["claude"]
skill_paths = generator.generate_core_skills(platform, temp_dir)
@@ -253,7 +253,7 @@ def test_generate_step_skill_different_platform(
job_dir = fixtures_dir / "jobs" / "simple_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
platform = PLATFORMS["gemini"]
skill_path = generator.generate_step_skill(
@@ -283,7 +283,7 @@ def test_generate_raises_for_missing_platform_templates(
job_dir = fixtures_dir / "jobs" / "simple_job"
job = parse_job_definition(job_dir)
- generator = SkillGenerator()
+ generator = CommandGenerator()
# Gemini templates don't exist
platform = PLATFORMS["gemini"]
diff --git a/tests/unit/test_hooks_syncer.py b/tests/unit/test_hooks_syncer.py
new file mode 100644
index 00000000..2750eefb
--- /dev/null
+++ b/tests/unit/test_hooks_syncer.py
@@ -0,0 +1,327 @@
+"""Tests for the hooks syncer module."""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from deepwork.core.hooks_syncer import (
+ HookEntry,
+ HooksSyncError,
+ JobHooks,
+ collect_job_hooks,
+ merge_hooks_for_platform,
+ sync_hooks_to_claude,
+)
+from deepwork.core.detector import PlatformConfig
+
+
+class TestHookEntry:
+ """Tests for HookEntry dataclass."""
+
+ def test_get_script_path_relative(self, temp_dir: Path) -> None:
+ """Test getting relative script path."""
+ job_dir = temp_dir / ".deepwork" / "jobs" / "test_job"
+ job_dir.mkdir(parents=True)
+
+ entry = HookEntry(
+ script="test_hook.sh",
+ job_name="test_job",
+ job_dir=job_dir,
+ )
+
+ path = entry.get_script_path(temp_dir)
+ assert path == ".deepwork/jobs/test_job/hooks/test_hook.sh"
+
+
+class TestJobHooks:
+ """Tests for JobHooks dataclass."""
+
+ def test_from_job_dir_with_hooks(self, temp_dir: Path) -> None:
+ """Test loading hooks from job directory."""
+ job_dir = temp_dir / "test_job"
+ hooks_dir = job_dir / "hooks"
+ hooks_dir.mkdir(parents=True)
+
+ # Create global_hooks.yml
+ hooks_file = hooks_dir / "global_hooks.yml"
+ hooks_file.write_text(
+ """
+UserPromptSubmit:
+ - capture.sh
+Stop:
+ - policy_check.sh
+ - cleanup.sh
+"""
+ )
+
+ result = JobHooks.from_job_dir(job_dir)
+
+ assert result is not None
+ assert result.job_name == "test_job"
+ assert result.hooks["UserPromptSubmit"] == ["capture.sh"]
+ assert result.hooks["Stop"] == ["policy_check.sh", "cleanup.sh"]
+
+ def test_from_job_dir_no_hooks_file(self, temp_dir: Path) -> None:
+ """Test returns None when no hooks file exists."""
+ job_dir = temp_dir / "test_job"
+ job_dir.mkdir(parents=True)
+
+ result = JobHooks.from_job_dir(job_dir)
+ assert result is None
+
+ def test_from_job_dir_empty_hooks_file(self, temp_dir: Path) -> None:
+ """Test returns None when hooks file is empty."""
+ job_dir = temp_dir / "test_job"
+ hooks_dir = job_dir / "hooks"
+ hooks_dir.mkdir(parents=True)
+
+ hooks_file = hooks_dir / "global_hooks.yml"
+ hooks_file.write_text("")
+
+ result = JobHooks.from_job_dir(job_dir)
+ assert result is None
+
+ def test_from_job_dir_single_script_as_string(self, temp_dir: Path) -> None:
+ """Test parsing single script as string instead of list."""
+ job_dir = temp_dir / "test_job"
+ hooks_dir = job_dir / "hooks"
+ hooks_dir.mkdir(parents=True)
+
+ hooks_file = hooks_dir / "global_hooks.yml"
+ hooks_file.write_text("Stop: cleanup.sh\n")
+
+ result = JobHooks.from_job_dir(job_dir)
+
+ assert result is not None
+ assert result.hooks["Stop"] == ["cleanup.sh"]
+
+
+class TestCollectJobHooks:
+ """Tests for collect_job_hooks function."""
+
+ def test_collects_hooks_from_multiple_jobs(self, temp_dir: Path) -> None:
+ """Test collecting hooks from multiple job directories."""
+ jobs_dir = temp_dir / "jobs"
+
+ # Create first job with hooks
+ job1_dir = jobs_dir / "job1"
+ (job1_dir / "hooks").mkdir(parents=True)
+ (job1_dir / "hooks" / "global_hooks.yml").write_text("Stop:\n - hook1.sh\n")
+
+ # Create second job with hooks
+ job2_dir = jobs_dir / "job2"
+ (job2_dir / "hooks").mkdir(parents=True)
+ (job2_dir / "hooks" / "global_hooks.yml").write_text("Stop:\n - hook2.sh\n")
+
+ # Create job without hooks
+ job3_dir = jobs_dir / "job3"
+ job3_dir.mkdir(parents=True)
+
+ result = collect_job_hooks(jobs_dir)
+
+ assert len(result) == 2
+ job_names = {jh.job_name for jh in result}
+ assert job_names == {"job1", "job2"}
+
+ def test_returns_empty_for_nonexistent_dir(self, temp_dir: Path) -> None:
+ """Test returns empty list when jobs dir doesn't exist."""
+ jobs_dir = temp_dir / "nonexistent"
+ result = collect_job_hooks(jobs_dir)
+ assert result == []
+
+
+class TestMergeHooksForPlatform:
+ """Tests for merge_hooks_for_platform function."""
+
+ def test_merges_hooks_from_multiple_jobs(self, temp_dir: Path) -> None:
+ """Test merging hooks from multiple jobs."""
+ # Create job directories
+ job1_dir = temp_dir / ".deepwork" / "jobs" / "job1"
+ job2_dir = temp_dir / ".deepwork" / "jobs" / "job2"
+ job1_dir.mkdir(parents=True)
+ job2_dir.mkdir(parents=True)
+
+ job_hooks_list = [
+ JobHooks(
+ job_name="job1",
+ job_dir=job1_dir,
+ hooks={"Stop": ["hook1.sh"]},
+ ),
+ JobHooks(
+ job_name="job2",
+ job_dir=job2_dir,
+ hooks={"Stop": ["hook2.sh"], "UserPromptSubmit": ["capture.sh"]},
+ ),
+ ]
+
+ result = merge_hooks_for_platform(job_hooks_list, temp_dir)
+
+ assert "Stop" in result
+ assert "UserPromptSubmit" in result
+ assert len(result["Stop"]) == 2
+ assert len(result["UserPromptSubmit"]) == 1
+
+ def test_avoids_duplicate_hooks(self, temp_dir: Path) -> None:
+ """Test that duplicate hooks are not added."""
+ job_dir = temp_dir / ".deepwork" / "jobs" / "job1"
+ job_dir.mkdir(parents=True)
+
+ # Same hook in same job (shouldn't happen but test anyway)
+ job_hooks_list = [
+ JobHooks(
+ job_name="job1",
+ job_dir=job_dir,
+ hooks={"Stop": ["hook.sh", "hook.sh"]},
+ ),
+ ]
+
+ result = merge_hooks_for_platform(job_hooks_list, temp_dir)
+
+ # Should only have one entry
+ assert len(result["Stop"]) == 1
+
+
+class TestSyncHooksToClaude:
+ """Tests for sync_hooks_to_claude function."""
+
+ def test_creates_settings_file(self, temp_dir: Path) -> None:
+ """Test creating settings.json when it doesn't exist."""
+ platform = PlatformConfig(
+ name="claude",
+ display_name="Claude Code",
+ config_dir=".claude",
+ commands_dir="commands",
+ )
+
+ # Create .claude directory
+ (temp_dir / ".claude").mkdir(parents=True)
+
+ hooks = {
+ "Stop": [
+ {
+ "matcher": "",
+ "hooks": [{"type": "command", "command": "test_hook.sh"}],
+ }
+ ]
+ }
+
+ sync_hooks_to_claude(temp_dir, platform, hooks)
+
+ settings_file = temp_dir / ".claude" / "settings.json"
+ assert settings_file.exists()
+
+ with open(settings_file) as f:
+ settings = json.load(f)
+
+ assert "hooks" in settings
+ assert "Stop" in settings["hooks"]
+ assert len(settings["hooks"]["Stop"]) == 1
+
+ def test_merges_with_existing_settings(self, temp_dir: Path) -> None:
+ """Test merging hooks into existing settings.json."""
+ platform = PlatformConfig(
+ name="claude",
+ display_name="Claude Code",
+ config_dir=".claude",
+ commands_dir="commands",
+ )
+
+ # Create .claude directory with existing settings
+ claude_dir = temp_dir / ".claude"
+ claude_dir.mkdir(parents=True)
+
+ existing_settings = {
+ "version": "1.0",
+ "hooks": {
+ "PreToolUse": [
+ {"matcher": "", "hooks": [{"type": "command", "command": "existing.sh"}]}
+ ]
+ },
+ }
+ settings_file = claude_dir / "settings.json"
+ with open(settings_file, "w") as f:
+ json.dump(existing_settings, f)
+
+ hooks = {
+ "Stop": [
+ {
+ "matcher": "",
+ "hooks": [{"type": "command", "command": "new_hook.sh"}],
+ }
+ ]
+ }
+
+ sync_hooks_to_claude(temp_dir, platform, hooks)
+
+ with open(settings_file) as f:
+ settings = json.load(f)
+
+ # Should preserve existing settings
+ assert settings["version"] == "1.0"
+ assert "PreToolUse" in settings["hooks"]
+
+ # Should add new hooks
+ assert "Stop" in settings["hooks"]
+ assert len(settings["hooks"]["Stop"]) == 1
+
+ def test_does_not_duplicate_existing_hooks(self, temp_dir: Path) -> None:
+ """Test that existing hooks are not duplicated."""
+ platform = PlatformConfig(
+ name="claude",
+ display_name="Claude Code",
+ config_dir=".claude",
+ commands_dir="commands",
+ )
+
+ claude_dir = temp_dir / ".claude"
+ claude_dir.mkdir(parents=True)
+
+ # Settings with existing hook
+ existing_settings = {
+ "hooks": {
+ "Stop": [
+ {"matcher": "", "hooks": [{"type": "command", "command": "hook.sh"}]}
+ ]
+ },
+ }
+ settings_file = claude_dir / "settings.json"
+ with open(settings_file, "w") as f:
+ json.dump(existing_settings, f)
+
+ # Try to add the same hook
+ hooks = {
+ "Stop": [
+ {
+ "matcher": "",
+ "hooks": [{"type": "command", "command": "hook.sh"}],
+ }
+ ]
+ }
+
+ sync_hooks_to_claude(temp_dir, platform, hooks)
+
+ with open(settings_file) as f:
+ settings = json.load(f)
+
+ # Should still only have one hook
+ assert len(settings["hooks"]["Stop"]) == 1
+
+ def test_handles_empty_hooks(self, temp_dir: Path) -> None:
+ """Test that empty hooks dict doesn't modify settings."""
+ platform = PlatformConfig(
+ name="claude",
+ display_name="Claude Code",
+ config_dir=".claude",
+ commands_dir="commands",
+ )
+
+ claude_dir = temp_dir / ".claude"
+ claude_dir.mkdir(parents=True)
+
+ # No settings file exists initially
+ sync_hooks_to_claude(temp_dir, platform, {})
+
+ # Settings file should not be created
+ settings_file = claude_dir / "settings.json"
+ assert not settings_file.exists()
diff --git a/tests/unit/test_policy_parser.py b/tests/unit/test_policy_parser.py
new file mode 100644
index 00000000..8e79cfca
--- /dev/null
+++ b/tests/unit/test_policy_parser.py
@@ -0,0 +1,358 @@
+"""Tests for policy definition parser."""
+
+from pathlib import Path
+
+import pytest
+
+from deepwork.core.policy_parser import (
+ Policy,
+ PolicyParseError,
+ evaluate_policies,
+ evaluate_policy,
+ matches_pattern,
+ parse_policy_file,
+)
+
+
+class TestPolicy:
+ """Tests for Policy dataclass."""
+
+ def test_from_dict_with_inline_instructions(self) -> None:
+ """Test creating policy from dict with inline instructions."""
+ data = {
+ "name": "Test Policy",
+ "trigger": "src/**/*",
+ "safety": "docs/readme.md",
+ "instructions": "Do something",
+ }
+ policy = Policy.from_dict(data)
+
+ assert policy.name == "Test Policy"
+ assert policy.triggers == ["src/**/*"]
+ assert policy.safety == ["docs/readme.md"]
+ assert policy.instructions == "Do something"
+
+ def test_from_dict_normalizes_trigger_string_to_list(self) -> None:
+ """Test that trigger string is normalized to list."""
+ data = {
+ "name": "Test",
+ "trigger": "*.py",
+ "instructions": "Check it",
+ }
+ policy = Policy.from_dict(data)
+
+ assert policy.triggers == ["*.py"]
+
+ def test_from_dict_preserves_trigger_list(self) -> None:
+ """Test that trigger list is preserved."""
+ data = {
+ "name": "Test",
+ "trigger": ["*.py", "*.js"],
+ "instructions": "Check it",
+ }
+ policy = Policy.from_dict(data)
+
+ assert policy.triggers == ["*.py", "*.js"]
+
+ def test_from_dict_normalizes_safety_string_to_list(self) -> None:
+ """Test that safety string is normalized to list."""
+ data = {
+ "name": "Test",
+ "trigger": "src/*",
+ "safety": "docs/README.md",
+ "instructions": "Check it",
+ }
+ policy = Policy.from_dict(data)
+
+ assert policy.safety == ["docs/README.md"]
+
+ def test_from_dict_safety_defaults_to_empty_list(self) -> None:
+ """Test that missing safety defaults to empty list."""
+ data = {
+ "name": "Test",
+ "trigger": "src/*",
+ "instructions": "Check it",
+ }
+ policy = Policy.from_dict(data)
+
+ assert policy.safety == []
+
+ def test_from_dict_with_instructions_file(self, temp_dir: Path) -> None:
+ """Test creating policy from dict with instructions_file."""
+ # Create instructions file
+ instructions_file = temp_dir / "instructions.md"
+ instructions_file.write_text("# Instructions\nDo this and that.")
+
+ data = {
+ "name": "Test Policy",
+ "trigger": "src/*",
+ "instructions_file": "instructions.md",
+ }
+ policy = Policy.from_dict(data, base_dir=temp_dir)
+
+ assert policy.instructions == "# Instructions\nDo this and that."
+
+ def test_from_dict_instructions_file_not_found(self, temp_dir: Path) -> None:
+ """Test error when instructions_file doesn't exist."""
+ data = {
+ "name": "Test Policy",
+ "trigger": "src/*",
+ "instructions_file": "nonexistent.md",
+ }
+
+ with pytest.raises(PolicyParseError, match="instructions file not found"):
+ Policy.from_dict(data, base_dir=temp_dir)
+
+ def test_from_dict_instructions_file_without_base_dir(self) -> None:
+ """Test error when instructions_file used without base_dir."""
+ data = {
+ "name": "Test Policy",
+ "trigger": "src/*",
+ "instructions_file": "instructions.md",
+ }
+
+ with pytest.raises(PolicyParseError, match="no base_dir provided"):
+ Policy.from_dict(data, base_dir=None)
+
+
+class TestMatchesPattern:
+ """Tests for matches_pattern function."""
+
+ def test_simple_glob_match(self) -> None:
+ """Test simple glob pattern matching."""
+ assert matches_pattern("file.py", ["*.py"])
+ assert not matches_pattern("file.js", ["*.py"])
+
+ def test_directory_glob_match(self) -> None:
+ """Test directory pattern matching."""
+ assert matches_pattern("src/file.py", ["src/*"])
+ assert not matches_pattern("test/file.py", ["src/*"])
+
+ def test_recursive_glob_match(self) -> None:
+ """Test recursive ** pattern matching."""
+ assert matches_pattern("src/deep/nested/file.py", ["src/**/*.py"])
+ assert matches_pattern("src/file.py", ["src/**/*.py"])
+ assert not matches_pattern("test/file.py", ["src/**/*.py"])
+
+ def test_multiple_patterns(self) -> None:
+ """Test matching against multiple patterns."""
+ patterns = ["*.py", "*.js"]
+ assert matches_pattern("file.py", patterns)
+ assert matches_pattern("file.js", patterns)
+ assert not matches_pattern("file.txt", patterns)
+
+ def test_config_directory_pattern(self) -> None:
+ """Test pattern like app/config/**/*."""
+ assert matches_pattern("app/config/settings.py", ["app/config/**/*"])
+ assert matches_pattern("app/config/nested/deep.yml", ["app/config/**/*"])
+ assert not matches_pattern("app/other/file.py", ["app/config/**/*"])
+
+
+class TestEvaluatePolicy:
+ """Tests for evaluate_policy function."""
+
+ def test_fires_when_trigger_matches(self) -> None:
+ """Test policy fires when trigger matches."""
+ policy = Policy(
+ name="Test",
+ triggers=["src/**/*.py"],
+ safety=[],
+ instructions="Check it",
+ )
+ changed_files = ["src/main.py", "README.md"]
+
+ assert evaluate_policy(policy, changed_files) is True
+
+ def test_does_not_fire_when_no_trigger_match(self) -> None:
+ """Test policy doesn't fire when no trigger matches."""
+ policy = Policy(
+ name="Test",
+ triggers=["src/**/*.py"],
+ safety=[],
+ instructions="Check it",
+ )
+ changed_files = ["test/main.py", "README.md"]
+
+ assert evaluate_policy(policy, changed_files) is False
+
+ def test_does_not_fire_when_safety_matches(self) -> None:
+ """Test policy doesn't fire when safety file is also changed."""
+ policy = Policy(
+ name="Test",
+ triggers=["app/config/**/*"],
+ safety=["docs/install_guide.md"],
+ instructions="Update docs",
+ )
+ changed_files = ["app/config/settings.py", "docs/install_guide.md"]
+
+ assert evaluate_policy(policy, changed_files) is False
+
+ def test_fires_when_trigger_matches_but_safety_doesnt(self) -> None:
+ """Test policy fires when trigger matches but safety doesn't."""
+ policy = Policy(
+ name="Test",
+ triggers=["app/config/**/*"],
+ safety=["docs/install_guide.md"],
+ instructions="Update docs",
+ )
+ changed_files = ["app/config/settings.py", "app/main.py"]
+
+ assert evaluate_policy(policy, changed_files) is True
+
+ def test_multiple_safety_patterns(self) -> None:
+ """Test policy with multiple safety patterns."""
+ policy = Policy(
+ name="Test",
+ triggers=["src/auth/**/*"],
+ safety=["SECURITY.md", "docs/security_review.md"],
+ instructions="Security review",
+ )
+
+ # Should not fire if any safety file is changed
+ assert evaluate_policy(policy, ["src/auth/login.py", "SECURITY.md"]) is False
+ assert (
+ evaluate_policy(policy, ["src/auth/login.py", "docs/security_review.md"])
+ is False
+ )
+
+ # Should fire if no safety files changed
+ assert evaluate_policy(policy, ["src/auth/login.py"]) is True
+
+
+class TestEvaluatePolicies:
+ """Tests for evaluate_policies function."""
+
+ def test_returns_fired_policies(self) -> None:
+ """Test that evaluate_policies returns all fired policies."""
+ policies = [
+ Policy(
+ name="Policy 1",
+ triggers=["src/**/*"],
+ safety=[],
+ instructions="Do 1",
+ ),
+ Policy(
+ name="Policy 2",
+ triggers=["test/**/*"],
+ safety=[],
+ instructions="Do 2",
+ ),
+ ]
+ changed_files = ["src/main.py", "test/test_main.py"]
+
+ fired = evaluate_policies(policies, changed_files)
+
+ assert len(fired) == 2
+ assert fired[0].name == "Policy 1"
+ assert fired[1].name == "Policy 2"
+
+ def test_skips_promised_policies(self) -> None:
+ """Test that promised policies are skipped."""
+ policies = [
+ Policy(
+ name="Policy 1",
+ triggers=["src/**/*"],
+ safety=[],
+ instructions="Do 1",
+ ),
+ Policy(
+ name="Policy 2",
+ triggers=["src/**/*"],
+ safety=[],
+ instructions="Do 2",
+ ),
+ ]
+ changed_files = ["src/main.py"]
+ promised = {"Policy 1"}
+
+ fired = evaluate_policies(policies, changed_files, promised)
+
+ assert len(fired) == 1
+ assert fired[0].name == "Policy 2"
+
+ def test_returns_empty_when_no_policies_fire(self) -> None:
+ """Test returns empty list when no policies fire."""
+ policies = [
+ Policy(
+ name="Policy 1",
+ triggers=["src/**/*"],
+ safety=[],
+ instructions="Do 1",
+ ),
+ ]
+ changed_files = ["test/test_main.py"]
+
+ fired = evaluate_policies(policies, changed_files)
+
+ assert len(fired) == 0
+
+
+class TestParsePolicyFile:
+ """Tests for parse_policy_file function."""
+
+ def test_parses_valid_policy_file(self, fixtures_dir: Path) -> None:
+ """Test parsing a valid policy file."""
+ policy_file = fixtures_dir / "policies" / "valid_policy.yml"
+ policies = parse_policy_file(policy_file)
+
+ assert len(policies) == 1
+ assert policies[0].name == "Update install guide on config changes"
+ assert policies[0].triggers == ["app/config/**/*"]
+ assert policies[0].safety == ["docs/install_guide.md"]
+ assert "Configuration files have changed" in policies[0].instructions
+
+ def test_parses_multiple_policies(self, fixtures_dir: Path) -> None:
+ """Test parsing a file with multiple policies."""
+ policy_file = fixtures_dir / "policies" / "multiple_policies.yml"
+ policies = parse_policy_file(policy_file)
+
+ assert len(policies) == 3
+ assert policies[0].name == "Update install guide on config changes"
+ assert policies[1].name == "Security review for auth changes"
+ assert policies[2].name == "API documentation update"
+
+ # Check that arrays are parsed correctly
+ assert policies[1].triggers == ["src/auth/**/*", "src/security/**/*"]
+ assert policies[1].safety == ["SECURITY.md", "docs/security_review.md"]
+
+ def test_parses_policy_with_instructions_file(self, fixtures_dir: Path) -> None:
+ """Test parsing a policy with instructions_file."""
+ policy_file = fixtures_dir / "policies" / "policy_with_instructions_file.yml"
+ policies = parse_policy_file(policy_file)
+
+ assert len(policies) == 1
+ assert "Security Review Required" in policies[0].instructions
+ assert "hardcoded credentials" in policies[0].instructions
+
+ def test_empty_policy_file_returns_empty_list(self, fixtures_dir: Path) -> None:
+ """Test that empty policy file returns empty list."""
+ policy_file = fixtures_dir / "policies" / "empty_policy.yml"
+ policies = parse_policy_file(policy_file)
+
+ assert policies == []
+
+ def test_raises_for_missing_trigger(self, fixtures_dir: Path) -> None:
+ """Test error when policy is missing trigger."""
+ policy_file = fixtures_dir / "policies" / "invalid_missing_trigger.yml"
+
+ with pytest.raises(PolicyParseError, match="validation failed"):
+ parse_policy_file(policy_file)
+
+ def test_raises_for_missing_instructions(self, fixtures_dir: Path) -> None:
+ """Test error when policy is missing both instructions and instructions_file."""
+ policy_file = fixtures_dir / "policies" / "invalid_missing_instructions.yml"
+
+ with pytest.raises(PolicyParseError, match="validation failed"):
+ parse_policy_file(policy_file)
+
+ def test_raises_for_nonexistent_file(self, temp_dir: Path) -> None:
+ """Test error when policy file doesn't exist."""
+ policy_file = temp_dir / "nonexistent.yml"
+
+ with pytest.raises(PolicyParseError, match="does not exist"):
+ parse_policy_file(policy_file)
+
+ def test_raises_for_directory_path(self, temp_dir: Path) -> None:
+ """Test error when path is a directory."""
+ with pytest.raises(PolicyParseError, match="is not a file"):
+ parse_policy_file(temp_dir)