From 21102db2e55a3918e184c9bdf3c9eaf6b7e4ec35 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Sun, 11 Jan 2026 21:16:50 -0700 Subject: [PATCH 1/2] policies working! --- .claude/commands/deepwork_jobs.define.md | 15 +- .claude/commands/deepwork_jobs.implement.md | 20 +- .claude/commands/deepwork_jobs.refine.md | 23 +- .claude/commands/deepwork_policy.define.md | 275 ++++++++++++ .claude/settings.json | 24 ++ .deepwork.policy.yml | 19 + README.md | 30 ++ doc/TEMPLATE_REVIEW.md | 400 ------------------ doc/architecture.md | 166 +++++++- .../example1_simple_job_user_inputs.md | 97 ----- .../example2_complex_job_file_inputs.md | 103 ----- .../example3_final_step_multiple_inputs.md | 112 ----- src/deepwork/cli/install.py | 78 +++- src/deepwork/cli/sync.py | 23 +- src/deepwork/core/hooks_syncer.py | 267 ++++++++++++ src/deepwork/core/policy_parser.py | 285 +++++++++++++ src/deepwork/hooks/__init__.py | 1 + src/deepwork/hooks/evaluate_policies.py | 163 +++++++ src/deepwork/schemas/policy_schema.py | 68 +++ .../hooks/capture_work_tree.sh | 26 ++ .../hooks/get_changed_files.sh | 30 ++ .../deepwork_policy/hooks/global_hooks.yml | 8 + .../deepwork_policy/hooks/policy_stop_hook.sh | 57 +++ .../hooks/user_prompt_submit.sh | 17 + .../standard_jobs/deepwork_policy/job.yml | 35 ++ .../deepwork_policy/steps/define.md | 174 ++++++++ tests/fixtures/policies/empty_policy.yml | 1 + .../policies/instructions/security_review.md | 8 + .../policies/invalid_missing_instructions.yml | 2 + .../policies/invalid_missing_trigger.yml | 3 + tests/fixtures/policies/multiple_policies.yml | 21 + .../policy_with_instructions_file.yml | 3 + tests/fixtures/policies/valid_policy.yml | 6 + tests/integration/test_full_workflow.py | 14 +- tests/unit/test_evaluate_policies.py | 113 +++++ tests/unit/test_generator.py | 32 +- tests/unit/test_hooks_syncer.py | 327 ++++++++++++++ tests/unit/test_policy_parser.py | 358 ++++++++++++++++ 38 files changed, 2618 insertions(+), 786 deletions(-) create mode 100644 .claude/commands/deepwork_policy.define.md create mode 100644 .deepwork.policy.yml delete mode 100644 doc/TEMPLATE_REVIEW.md delete mode 100644 doc/template_examples/example1_simple_job_user_inputs.md delete mode 100644 doc/template_examples/example2_complex_job_file_inputs.md delete mode 100644 doc/template_examples/example3_final_step_multiple_inputs.md create mode 100644 src/deepwork/core/hooks_syncer.py create mode 100644 src/deepwork/core/policy_parser.py create mode 100644 src/deepwork/hooks/__init__.py create mode 100644 src/deepwork/hooks/evaluate_policies.py create mode 100644 src/deepwork/schemas/policy_schema.py create mode 100755 src/deepwork/standard_jobs/deepwork_policy/hooks/capture_work_tree.sh create mode 100755 src/deepwork/standard_jobs/deepwork_policy/hooks/get_changed_files.sh create mode 100644 src/deepwork/standard_jobs/deepwork_policy/hooks/global_hooks.yml create mode 100755 src/deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh create mode 100755 src/deepwork/standard_jobs/deepwork_policy/hooks/user_prompt_submit.sh create mode 100644 src/deepwork/standard_jobs/deepwork_policy/job.yml create mode 100644 src/deepwork/standard_jobs/deepwork_policy/steps/define.md create mode 100644 tests/fixtures/policies/empty_policy.yml create mode 100644 tests/fixtures/policies/instructions/security_review.md create mode 100644 tests/fixtures/policies/invalid_missing_instructions.yml create mode 100644 tests/fixtures/policies/invalid_missing_trigger.yml create mode 100644 tests/fixtures/policies/multiple_policies.yml create mode 100644 tests/fixtures/policies/policy_with_instructions_file.yml create mode 100644 tests/fixtures/policies/valid_policy.yml create mode 100644 tests/unit/test_evaluate_policies.py create mode 100644 tests/unit/test_hooks_syncer.py create mode 100644 tests/unit/test_policy_parser.py 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..32b02367 100644 --- a/.claude/commands/deepwork_jobs.refine.md +++ b/.claude/commands/deepwork_jobs.refine.md @@ -439,21 +439,28 @@ 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**: +3. **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 + - Outputs created: job.yml - This is the final step - the job is complete! ## Workflow Complete diff --git a/.claude/commands/deepwork_policy.define.md b/.claude/commands/deepwork_policy.define.md new file mode 100644 index 00000000..bc72831b --- /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 + +**Step 1 of 1** in the **deepwork_policy** workflow + +**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**: + - Step 1 of 1 is complete + - Outputs created: .deepwork.policy.yml + - This is the final step - the job is complete! + +## Workflow Complete + +This is the final step in the deepwork_policy workflow. All outputs should now be complete and ready for review. + +Consider: +- Reviewing all work products in `deepwork/deepwork_policy/` +- Creating a pull request to merge the work branch +- Documenting any insights or learnings + +--- + +## 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/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..ecd80c57 --- /dev/null +++ b/src/deepwork/hooks/evaluate_policies.py @@ -0,0 +1,163 @@ +""" +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 hooks: + {"continue": true, "systemMessage": "..."} # Policies need attention + {} # No policies fired, exit normally +""" + +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 = ["## Policies Triggered", ""] + lines.append( + "The following policies have been triggered based on the files you changed. " + "Please address each one." + ) + lines.append("") + lines.append( + "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 hooks + message = format_policy_message(fired_policies) + result = { + "continue": True, + "systemMessage": 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..4a50b75f --- /dev/null +++ b/src/deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh @@ -0,0 +1,57 @@ +#!/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 agent's response +# 4. Returns JSON to continue 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 + +# 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 + +# Call Python module to evaluate policies +# The Python module handles: +# - Parsing the policy file +# - Matching changed files against triggers/safety patterns +# - Checking for promise tags in stdin (the conversation context) +# - Generating appropriate JSON output + +# Read the conversation context from stdin (if available) +conversation_context="" +if [ -t 0 ]; then + # No stdin available + conversation_context="" +else + conversation_context=$(cat) +fi + +# Call the Python evaluator +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/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..beb222cd --- /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 "## 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) From 7fd938cf2cacbb9d37b719a58d23f5bb2f7ee83f Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 12 Jan 2026 08:26:37 -0700 Subject: [PATCH 2/2] first policy in place --- .claude/commands/AGENTS.md | 5 +++ .claude/commands/deepwork_jobs.refine.md | 16 +++---- .claude/commands/deepwork_policy.define.md | 16 +++---- src/deepwork/core/generator.py | 39 +++++++++++++--- src/deepwork/hooks/evaluate_policies.py | 21 ++++----- .../deepwork_policy/hooks/policy_stop_hook.sh | 45 ++++++++++++------- .../claude/command-job-step.md.jinja | 21 ++++++++- tests/unit/test_evaluate_policies.py | 2 +- 8 files changed, 115 insertions(+), 50 deletions(-) create mode 100644 .claude/commands/AGENTS.md 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.refine.md b/.claude/commands/deepwork_jobs.refine.md index 32b02367..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 @@ -459,18 +459,18 @@ After completing this step: 2. **Verify outputs**: Confirm all required files have been created 3. **Inform the user**: - - Step 3 of 3 is complete + - The refine command is complete - Outputs created: job.yml - - This is the final step - the job is complete! + - 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 index bc72831b..4269712a 100644 --- a/.claude/commands/deepwork_policy.define.md +++ b/.claude/commands/deepwork_policy.define.md @@ -4,7 +4,7 @@ description: Create or update policy entries in .deepwork.policy.yml # deepwork_policy.define -**Step 1 of 1** in the **deepwork_policy** workflow +**Standalone command** in the **deepwork_policy** job - can be run anytime **Summary**: Policy enforcement for AI agent sessions @@ -254,18 +254,18 @@ After completing this step: 2. **Verify outputs**: Confirm all required files have been created 3. **Inform the user**: - - Step 1 of 1 is complete + - The define command is complete - Outputs created: .deepwork.policy.yml - - This is the final step - the job is complete! + - This command can be run again anytime to make further changes -## Workflow Complete +## Command Complete -This is the final step in the deepwork_policy 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_policy/` -- Creating a pull request to merge the work branch -- Documenting any insights or learnings +- 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 --- 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/hooks/evaluate_policies.py b/src/deepwork/hooks/evaluate_policies.py index ecd80c57..e5af4950 100644 --- a/src/deepwork/hooks/evaluate_policies.py +++ b/src/deepwork/hooks/evaluate_policies.py @@ -12,9 +12,9 @@ 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 hooks: - {"continue": true, "systemMessage": "..."} # Policies need attention - {} # No policies fired, exit normally +Output is JSON suitable for Claude Code Stop hooks: + {"decision": "block", "reason": "..."} # Block stop, policies need attention + {} # No policies fired, allow stop """ import argparse @@ -60,13 +60,9 @@ def format_policy_message(policies: list) -> str: Returns: Formatted message with all policy instructions """ - lines = ["## Policies Triggered", ""] - lines.append( - "The following policies have been triggered based on the files you changed. " - "Please address each one." - ) - lines.append("") + lines = ["## DeepWork Policies Triggered", ""] lines.append( + "Comply with the following policies. " "To mark a policy as addressed, include `addressed` " "in your response." ) @@ -149,11 +145,12 @@ def main() -> None: print("{}") return - # Format output for Claude Code hooks + # Format output for Claude Code Stop hooks + # Use "decision": "block" to prevent Claude from stopping message = format_policy_message(fired_policies) result = { - "continue": True, - "systemMessage": message, + "decision": "block", + "reason": message, } print(json.dumps(result)) 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 index 4a50b75f..6d598a3e 100755 --- a/src/deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh +++ b/src/deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh @@ -4,8 +4,8 @@ # 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 agent's response -# 4. Returns JSON to continue if policies need attention +# 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 @@ -18,6 +18,19 @@ if [ ! -f .deepwork.policy.yml ]; then 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 "") @@ -28,23 +41,25 @@ if [ -z "${changed_files}" ]; then exit 0 fi -# Call Python module to evaluate policies -# The Python module handles: -# - Parsing the policy file -# - Matching changed files against triggers/safety patterns -# - Checking for promise tags in stdin (the conversation context) -# - Generating appropriate JSON output - -# Read the conversation context from stdin (if available) +# 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 [ -t 0 ]; then - # No stdin available - conversation_context="" -else - conversation_context=$(cat) +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}" \ 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/unit/test_evaluate_policies.py b/tests/unit/test_evaluate_policies.py index beb222cd..dfda87fd 100644 --- a/tests/unit/test_evaluate_policies.py +++ b/tests/unit/test_evaluate_policies.py @@ -69,7 +69,7 @@ def test_formats_single_policy(self) -> None: ] result = format_policy_message(policies) - assert "## Policies Triggered" in result + assert "## DeepWork Policies Triggered" in result assert "### Policy: Test Policy" in result assert "Please update the documentation." in result assert '' in result