From 7680c3bb9afbcd4416b067899c0b7f6dbb4f71fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 16:47:18 +0000 Subject: [PATCH 1/4] Add 'created' detection mode for rules system Implements a new rule detection mode that only triggers when files are newly created, not when existing files are modified. This enables rules to enforce standards specifically for new code. - Add CREATED detection mode to DetectionMode enum - Add created_patterns field to Rule dataclass - Update schema to validate 'created' field - Implement evaluate_created() function - Add get_created_files_* functions for each compare_to mode - Update evaluate_rule() and evaluate_rules() to handle created mode - Update documentation with examples and field reference - Add comprehensive tests for created mode (10 new tests) --- doc/rules_syntax.md | 108 +++++++++++++- doc/rules_system_design.md | 22 +++ src/deepwork/core/rules_parser.py | 57 ++++++- src/deepwork/hooks/rules_check.py | 150 ++++++++++++++++++- src/deepwork/schemas/rules_schema.py | 41 ++++- tests/unit/test_rules_parser.py | 214 +++++++++++++++++++++++++++ 6 files changed, 581 insertions(+), 11 deletions(-) diff --git a/doc/rules_syntax.md b/doc/rules_syntax.md index f4c3ae83..eb47b58f 100644 --- a/doc/rules_syntax.md +++ b/doc/rules_syntax.md @@ -95,6 +95,21 @@ This rule runs `ruff format` on any changed Python files to ensure consistent code style across the codebase. ``` +### Created Mode (file creation trigger) + +`.deepwork/rules/new-module-docs.md`: +```markdown +--- +name: New Module Documentation +created: src/**/*.py +--- +A new Python module was created. Please ensure: + +- Add module docstring explaining the purpose +- Update relevant documentation if adding a public API +- Consider adding tests for the new module +``` + ## Rule Structure Every rule has two orthogonal aspects: @@ -108,6 +123,7 @@ How the rule decides when to fire: | **Trigger/Safety** | `trigger`, `safety` | Fire when trigger matches and safety doesn't | | **Set** | `set` | Fire when file correspondence is incomplete (bidirectional) | | **Pair** | `pair` | Fire when file correspondence is incomplete (directional) | +| **Created** | `created` | Fire when newly created files match patterns | ### Action Type @@ -198,6 +214,47 @@ If `api/users/create.py` changes: If `docs/api/users/create.md` changes alone: - No trigger (documentation can be updated independently) +### Created Mode (File Creation Detection) + +Fires only when files are newly created (not modified). Useful for enforcing standards on new files. + +```yaml +--- +name: New Component Documentation +created: + - src/components/**/*.tsx + - src/components/**/*.ts +--- +``` + +**How it works:** + +1. A file is created that matches a `created` pattern +2. Rule fires with instructions + +Key differences from Trigger/Safety mode: +- Only fires for **new** files, not modifications to existing files +- No safety patterns (use Trigger/Safety mode if you need safety) +- Good for enforcing documentation, tests, or standards on new code + +**Examples:** + +```yaml +# Single pattern +created: src/api/**/*.py + +# Multiple patterns +created: + - src/models/**/*.py + - src/services/**/*.py +``` + +If a new file `src/api/users.py` is created: +- Rule fires with instructions for new API modules + +If an existing file `src/api/users.py` is modified: +- Rule does NOT fire (file already existed) + ## Action Types ### Prompt Action (Default) @@ -373,6 +430,22 @@ pair: --- ``` +### created + +File patterns that trigger when files are newly created (created mode). Only fires for new files, not modifications. Can be string or array. + +```yaml +--- +created: src/**/*.py +--- + +--- +created: + - src/**/*.py + - lib/**/*.py +--- +``` + ### action (optional) Specifies a command to run instead of prompting. @@ -503,6 +576,39 @@ This rule is suppressed if you've already modified pyproject.toml or CHANGELOG.md, as that indicates you're handling versioning. ``` +### Example 6: New File Standards (Created Mode) + +`.deepwork/rules/new-module-standards.md`: +```markdown +--- +name: New Module Standards +created: + - src/**/*.py + - lib/**/*.py +--- +A new Python module was created. Please ensure it follows our standards: + +1. **Module docstring**: Add a docstring at the top explaining the module's purpose +2. **Type hints**: Use type hints for all function parameters and return values +3. **Tests**: Create a corresponding test file in tests/ +4. **Imports**: Follow the import order (stdlib, third-party, local) + +This rule only fires for newly created files, not modifications. +``` + +### Example 7: New Component Checklist (Created Mode with Command) + +`.deepwork/rules/new-component-lint.md`: +```markdown +--- +name: New Component Lint +created: src/components/**/*.tsx +action: + command: eslint --fix {file} +--- +Automatically lints newly created React components. +``` + ## Promise Tags When a rule fires but should be dismissed, use promise tags in the conversation. The tag content should be human-readable, using the rule's `name` field: @@ -525,7 +631,7 @@ Error: .deepwork/rules/my-rule.md - invalid YAML frontmatter **Missing required field:** ``` -Error: .deepwork/rules/my-rule.md - must have 'trigger', 'set', or 'pair' +Error: .deepwork/rules/my-rule.md - must have 'trigger', 'set', 'pair', or 'created' ``` **Invalid pattern:** diff --git a/doc/rules_system_design.md b/doc/rules_system_design.md index 24e296b5..8fbf42b5 100644 --- a/doc/rules_system_design.md +++ b/doc/rules_system_design.md @@ -22,6 +22,7 @@ Every rule has two orthogonal aspects: | **Trigger/Safety** | `trigger`, `safety` | Fire when trigger matches and safety doesn't | | **Set** | `set` | Fire when file correspondence is incomplete (bidirectional) | | **Pair** | `pair` | Fire when file correspondence is incomplete (directional) | +| **Created** | `created` | Fire when newly created files match patterns | **Action Type** - What happens when the rule fires: @@ -47,6 +48,12 @@ Every rule has two orthogonal aspects: - Changes to expected files alone do not trigger the rule - Example: API code requires documentation updates +**Created Mode (File Creation Detection)** +- Define patterns for newly created files +- Only fires when files are created, not when existing files are modified +- Useful for enforcing standards on new code (documentation, tests, etc.) +- Example: New modules require documentation and tests + ### Pattern Variables Patterns use `{name}` syntax for capturing variable path segments: @@ -288,6 +295,21 @@ the pair rule does NOT trigger (directional). 7. Evaluator: If changes keep occurring, mark .failed, alert user ``` +### Created Rule + +``` +1. Detector: New file created, matches "src/**/*.py" created pattern +2. Detector: Verify file is newly created (not just modified) +3. Detector: Create .queued entry for new file rule +4. Evaluator: Return instructions for new file standards +5. Agent: Addresses rule, includes tag +6. Evaluator: On next check, mark .passed (promise found) +``` + +Note: Created mode uses separate file detection to distinguish newly +created files from modified files. Untracked files and files added +since the baseline are considered "created". + ## Agent Output Management ### Problem diff --git a/src/deepwork/core/rules_parser.py b/src/deepwork/core/rules_parser.py index 1de83a6c..816ea3e5 100644 --- a/src/deepwork/core/rules_parser.py +++ b/src/deepwork/core/rules_parser.py @@ -29,6 +29,7 @@ class DetectionMode(Enum): TRIGGER_SAFETY = "trigger_safety" # Fire when trigger matches, safety doesn't SET = "set" # Bidirectional file correspondence PAIR = "pair" # Directional file correspondence + CREATED = "created" # Fire when created files match patterns class ActionType(Enum): @@ -73,6 +74,7 @@ class Rule: safety: list[str] = field(default_factory=list) # For TRIGGER_SAFETY mode set_patterns: list[str] = field(default_factory=list) # For SET mode pair_config: PairConfig | None = None # For PAIR mode + created_patterns: list[str] = field(default_factory=list) # For CREATED mode # Action type action_type: ActionType = ActionType.PROMPT @@ -112,10 +114,13 @@ def from_frontmatter( has_trigger = "trigger" in frontmatter has_set = "set" in frontmatter has_pair = "pair" in frontmatter + has_created = "created" in frontmatter - mode_count = sum([has_trigger, has_set, has_pair]) + mode_count = sum([has_trigger, has_set, has_pair, has_created]) if mode_count == 0: - raise RulesParseError(f"Rule '{name}' must have 'trigger', 'set', or 'pair'") + raise RulesParseError( + f"Rule '{name}' must have 'trigger', 'set', 'pair', or 'created'" + ) if mode_count > 1: raise RulesParseError(f"Rule '{name}' has multiple detection modes - use only one") @@ -125,6 +130,7 @@ def from_frontmatter( safety: list[str] = [] set_patterns: list[str] = [] pair_config: PairConfig | None = None + created_patterns: list[str] = [] if has_trigger: detection_mode = DetectionMode.TRIGGER_SAFETY @@ -149,6 +155,11 @@ def from_frontmatter( expects=expects_list, ) + elif has_created: + detection_mode = DetectionMode.CREATED + created = frontmatter["created"] + created_patterns = [created] if isinstance(created, str) else list(created) + # Determine action type action_type: ActionType command_action: CommandAction | None = None @@ -177,6 +188,7 @@ def from_frontmatter( safety=safety, set_patterns=set_patterns, pair_config=pair_config, + created_patterns=created_patterns, action_type=action_type, instructions=markdown_body.strip(), command_action=command_action, @@ -418,6 +430,22 @@ def evaluate_pair_correspondence( return should_fire, trigger_files, missing_files +def evaluate_created( + rule: Rule, + created_files: list[str], +) -> bool: + """ + Evaluate a created mode rule. + + Returns True if rule should fire: + - At least one created file matches a created pattern + """ + for file_path in created_files: + if matches_any_pattern(file_path, rule.created_patterns): + return True + return False + + @dataclass class RuleEvaluationResult: """Result of evaluating a single rule.""" @@ -428,13 +456,18 @@ class RuleEvaluationResult: missing_files: list[str] = field(default_factory=list) # For set/pair modes -def evaluate_rule(rule: Rule, changed_files: list[str]) -> RuleEvaluationResult: +def evaluate_rule( + rule: Rule, + changed_files: list[str], + created_files: list[str] | None = None, +) -> RuleEvaluationResult: """ Evaluate whether a rule should fire based on changed files. Args: rule: Rule to evaluate changed_files: List of changed file paths (relative) + created_files: List of newly created file paths (relative), for CREATED mode Returns: RuleEvaluationResult with evaluation details @@ -472,6 +505,20 @@ def evaluate_rule(rule: Rule, changed_files: list[str]) -> RuleEvaluationResult: missing_files=missing_files, ) + elif rule.detection_mode == DetectionMode.CREATED: + files_to_check = created_files if created_files is not None else [] + should_fire = evaluate_created(rule, files_to_check) + trigger_files = ( + [f for f in files_to_check if matches_any_pattern(f, rule.created_patterns)] + if should_fire + else [] + ) + return RuleEvaluationResult( + rule=rule, + should_fire=should_fire, + trigger_files=trigger_files, + ) + return RuleEvaluationResult(rule=rule, should_fire=False) @@ -479,6 +526,7 @@ def evaluate_rules( rules: list[Rule], changed_files: list[str], promised_rules: set[str] | None = None, + created_files: list[str] | None = None, ) -> list[RuleEvaluationResult]: """ Evaluate which rules should fire. @@ -488,6 +536,7 @@ def evaluate_rules( changed_files: List of changed file paths (relative) promised_rules: Set of rule names that have been marked as addressed via tags (case-insensitive) + created_files: List of newly created file paths (relative), for CREATED mode Returns: List of RuleEvaluationResult for rules that should fire @@ -504,7 +553,7 @@ def evaluate_rules( if rule.name.lower() in promised_lower: continue - result = evaluate_rule(rule, changed_files) + result = evaluate_rule(rule, changed_files, created_files) if result.should_fire: results.append(result) diff --git a/src/deepwork/hooks/rules_check.py b/src/deepwork/hooks/rules_check.py index 2e6694c7..1e577a82 100644 --- a/src/deepwork/hooks/rules_check.py +++ b/src/deepwork/hooks/rules_check.py @@ -238,6 +238,149 @@ def get_changed_files_for_mode(mode: str) -> list[str]: return get_changed_files_base() +def get_created_files_base() -> list[str]: + """Get files created (added) relative to branch base.""" + default_branch = get_default_branch() + + try: + result = subprocess.run( + ["git", "merge-base", "HEAD", f"origin/{default_branch}"], + capture_output=True, + text=True, + check=True, + ) + merge_base = result.stdout.strip() + + subprocess.run(["git", "add", "-A"], capture_output=True, check=False) + + # Get only added files (not modified) using --diff-filter=A + result = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=A", merge_base, "HEAD"], + capture_output=True, + text=True, + check=True, + ) + committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() + + # Staged new files that don't exist in merge_base + result = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=A", "--cached", merge_base], + capture_output=True, + text=True, + check=False, + ) + staged_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() + + # Untracked files are by definition "created" + result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, + text=True, + check=False, + ) + untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() + + all_created = committed_added | staged_added | untracked_files + return sorted([f for f in all_created if f]) + + except subprocess.CalledProcessError: + return [] + + +def get_created_files_default_tip() -> list[str]: + """Get files created compared to default branch tip.""" + default_branch = get_default_branch() + + try: + subprocess.run(["git", "add", "-A"], capture_output=True, check=False) + + # Get only added files using --diff-filter=A + result = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=A", f"origin/{default_branch}..HEAD"], + capture_output=True, + text=True, + check=True, + ) + committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() + + result = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=A", "--cached", f"origin/{default_branch}"], + capture_output=True, + text=True, + check=False, + ) + staged_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() + + # Untracked files are by definition "created" + result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, + text=True, + check=False, + ) + untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() + + all_created = committed_added | staged_added | untracked_files + return sorted([f for f in all_created if f]) + + except subprocess.CalledProcessError: + return [] + + +def get_created_files_prompt() -> list[str]: + """Get files created since prompt was submitted.""" + baseline_path = Path(".deepwork/.last_work_tree") + + try: + subprocess.run(["git", "add", "-A"], capture_output=True, check=False) + + result = subprocess.run( + ["git", "diff", "--name-only", "--cached"], + capture_output=True, + text=True, + check=False, + ) + current_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() + current_files = {f for f in current_files if f} + + # Untracked files + result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, + text=True, + check=False, + ) + untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() + untracked_files = {f for f in untracked_files if f} + + all_current = current_files | untracked_files + + if baseline_path.exists(): + baseline_files = set(baseline_path.read_text().strip().split("\n")) + baseline_files = {f for f in baseline_files if f} + # Created files are those that didn't exist at baseline + created_files = all_current - baseline_files + return sorted(created_files) + else: + # No baseline means all current files are "new" to this prompt + return sorted(all_current) + + except (subprocess.CalledProcessError, OSError): + return [] + + +def get_created_files_for_mode(mode: str) -> list[str]: + """Get created files for a specific compare_to mode.""" + if mode == "base": + return get_created_files_base() + elif mode == "default_tip": + return get_created_files_default_tip() + elif mode == "prompt": + return get_created_files_prompt() + else: + return get_created_files_base() + + def extract_promise_tags(text: str) -> set[str]: """ Extract rule names from tags in text. @@ -399,13 +542,16 @@ def rules_check_hook(hook_input: HookInput) -> HookOutput: for mode, mode_rules in rules_by_mode.items(): changed_files = get_changed_files_for_mode(mode) - if not changed_files: + created_files = get_created_files_for_mode(mode) + + # Skip if no changed or created files + if not changed_files and not created_files: continue baseline_ref = get_baseline_ref(mode) # Evaluate which rules fire - results = evaluate_rules(mode_rules, changed_files, promised_rules) + results = evaluate_rules(mode_rules, changed_files, promised_rules, created_files) for result in results: rule = result.rule diff --git a/src/deepwork/schemas/rules_schema.py b/src/deepwork/schemas/rules_schema.py index 3112dd0f..cf275338 100644 --- a/src/deepwork/schemas/rules_schema.py +++ b/src/deepwork/schemas/rules_schema.py @@ -56,6 +56,11 @@ "additionalProperties": False, "description": "Directional file correspondence (trigger -> expects)", }, + # Detection mode: created (fire when files are created matching patterns) + "created": { + **STRING_OR_ARRAY, + "description": "Glob pattern(s) for newly created files that trigger this rule", + }, # Action type: command (default is prompt using markdown body) "action": { "type": "object", @@ -85,19 +90,47 @@ }, }, "additionalProperties": False, - # Detection mode must be exactly one of: trigger, set, or pair + # Detection mode must be exactly one of: trigger, set, pair, or created "oneOf": [ { "required": ["trigger"], - "not": {"anyOf": [{"required": ["set"]}, {"required": ["pair"]}]}, + "not": { + "anyOf": [ + {"required": ["set"]}, + {"required": ["pair"]}, + {"required": ["created"]}, + ] + }, }, { "required": ["set"], - "not": {"anyOf": [{"required": ["trigger"]}, {"required": ["pair"]}]}, + "not": { + "anyOf": [ + {"required": ["trigger"]}, + {"required": ["pair"]}, + {"required": ["created"]}, + ] + }, }, { "required": ["pair"], - "not": {"anyOf": [{"required": ["trigger"]}, {"required": ["set"]}]}, + "not": { + "anyOf": [ + {"required": ["trigger"]}, + {"required": ["set"]}, + {"required": ["created"]}, + ] + }, + }, + { + "required": ["created"], + "not": { + "anyOf": [ + {"required": ["trigger"]}, + {"required": ["set"]}, + {"required": ["pair"]}, + ] + }, }, ], } diff --git a/tests/unit/test_rules_parser.py b/tests/unit/test_rules_parser.py index 4aedea67..154dcc4b 100644 --- a/tests/unit/test_rules_parser.py +++ b/tests/unit/test_rules_parser.py @@ -731,3 +731,217 @@ def test_both_expects_only_no_fire(self) -> None: result = evaluate_rule(rule, changed_files) assert result.should_fire is False + + +class TestCreatedMode: + """Tests for created mode evaluation.""" + + def test_fires_when_created_file_matches(self) -> None: + """Test rule fires when a created file matches the pattern.""" + rule = Rule( + name="New Module Docs", + filename="new-module-docs", + detection_mode=DetectionMode.CREATED, + created_patterns=["src/**/*.py"], + instructions="Document the new module", + ) + created_files = ["src/new_module.py"] + + result = evaluate_rule(rule, [], created_files) + assert result.should_fire is True + assert "src/new_module.py" in result.trigger_files + + def test_does_not_fire_when_no_match(self) -> None: + """Test rule doesn't fire when no created file matches.""" + rule = Rule( + name="New Module Docs", + filename="new-module-docs", + detection_mode=DetectionMode.CREATED, + created_patterns=["src/**/*.py"], + instructions="Document the new module", + ) + created_files = ["tests/test_new.py"] + + result = evaluate_rule(rule, [], created_files) + assert result.should_fire is False + + def test_does_not_fire_for_modified_files(self) -> None: + """Test rule doesn't fire for modified files (only created).""" + rule = Rule( + name="New Module Docs", + filename="new-module-docs", + detection_mode=DetectionMode.CREATED, + created_patterns=["src/**/*.py"], + instructions="Document the new module", + ) + # File is in changed_files but NOT in created_files + changed_files = ["src/existing_module.py"] + created_files: list[str] = [] + + result = evaluate_rule(rule, changed_files, created_files) + assert result.should_fire is False + + def test_multiple_created_patterns(self) -> None: + """Test rule with multiple created patterns.""" + rule = Rule( + name="New Code Standards", + filename="new-code-standards", + detection_mode=DetectionMode.CREATED, + created_patterns=["src/**/*.py", "lib/**/*.py"], + instructions="Follow code standards", + ) + + # Matches first pattern + result1 = evaluate_rule(rule, [], ["src/foo.py"]) + assert result1.should_fire is True + + # Matches second pattern + result2 = evaluate_rule(rule, [], ["lib/bar.py"]) + assert result2.should_fire is True + + # Matches neither + result3 = evaluate_rule(rule, [], ["tests/test_foo.py"]) + assert result3.should_fire is False + + def test_created_with_nested_path(self) -> None: + """Test created mode with nested paths.""" + rule = Rule( + name="New Component", + filename="new-component", + detection_mode=DetectionMode.CREATED, + created_patterns=["src/components/**/*.tsx"], + instructions="Document the component", + ) + created_files = ["src/components/ui/Button.tsx"] + + result = evaluate_rule(rule, [], created_files) + assert result.should_fire is True + assert "src/components/ui/Button.tsx" in result.trigger_files + + def test_created_mixed_with_changed(self) -> None: + """Test that changed_files don't affect created mode rules.""" + rule = Rule( + name="New Module Docs", + filename="new-module-docs", + detection_mode=DetectionMode.CREATED, + created_patterns=["src/**/*.py"], + instructions="Document the new module", + ) + # src/existing.py is modified (in changed_files) + # src/new.py is created (in created_files) + changed_files = ["src/existing.py", "src/new.py"] + created_files = ["src/new.py"] + + result = evaluate_rule(rule, changed_files, created_files) + assert result.should_fire is True + # Only the created file should be in trigger_files + assert result.trigger_files == ["src/new.py"] + + def test_evaluate_rules_with_created_mode(self) -> None: + """Test evaluate_rules passes created_files correctly.""" + rules = [ + Rule( + name="Trigger Rule", + filename="trigger-rule", + detection_mode=DetectionMode.TRIGGER_SAFETY, + triggers=["src/**/*.py"], + safety=[], + instructions="Check source", + ), + Rule( + name="Created Rule", + filename="created-rule", + detection_mode=DetectionMode.CREATED, + created_patterns=["src/**/*.py"], + instructions="Document new files", + ), + ] + # src/existing.py is modified, src/new.py is created + changed_files = ["src/existing.py", "src/new.py"] + created_files = ["src/new.py"] + + results = evaluate_rules(rules, changed_files, None, created_files) + + # Both rules should fire + assert len(results) == 2 + rule_names = {r.rule.name for r in results} + assert "Trigger Rule" in rule_names + assert "Created Rule" in rule_names + + +class TestLoadCreatedModeRule: + """Tests for loading rules with created detection mode.""" + + def test_loads_rule_with_created_detection_mode(self, temp_dir: Path) -> None: + """Test loading a rule with created detection mode.""" + rules_dir = temp_dir / "rules" + rules_dir.mkdir() + + rule_file = rules_dir / "new-module-docs.md" + rule_file.write_text( + """--- +name: New Module Documentation +created: src/**/*.py +--- +A new Python module was created. Please add documentation. +""" + ) + + rules = load_rules_from_directory(rules_dir) + + assert len(rules) == 1 + assert rules[0].name == "New Module Documentation" + assert rules[0].detection_mode == DetectionMode.CREATED + assert rules[0].created_patterns == ["src/**/*.py"] + + def test_loads_rule_with_multiple_created_patterns(self, temp_dir: Path) -> None: + """Test loading a rule with multiple created patterns.""" + rules_dir = temp_dir / "rules" + rules_dir.mkdir() + + rule_file = rules_dir / "new-code-standards.md" + rule_file.write_text( + """--- +name: New Code Standards +created: + - src/**/*.py + - lib/**/*.py +--- +New code must follow standards. +""" + ) + + rules = load_rules_from_directory(rules_dir) + + assert len(rules) == 1 + assert rules[0].name == "New Code Standards" + assert rules[0].detection_mode == DetectionMode.CREATED + assert rules[0].created_patterns == ["src/**/*.py", "lib/**/*.py"] + + def test_loads_created_rule_with_command_action(self, temp_dir: Path) -> None: + """Test loading a created mode rule with command action.""" + rules_dir = temp_dir / "rules" + rules_dir.mkdir() + + rule_file = rules_dir / "new-file-lint.md" + rule_file.write_text( + """--- +name: New File Lint +created: "**/*.py" +action: + command: "ruff check {file}" + run_for: each_match +--- +""" + ) + + rules = load_rules_from_directory(rules_dir) + + assert len(rules) == 1 + assert rules[0].name == "New File Lint" + assert rules[0].detection_mode == DetectionMode.CREATED + from deepwork.core.rules_parser import ActionType + + assert rules[0].action_type == ActionType.COMMAND + assert rules[0].command_action is not None + assert rules[0].command_action.command == "ruff check {file}" From 4e3afcf7534ea23633cf22be15e1a4f002cc2a52 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 18:13:44 +0000 Subject: [PATCH 2/4] Add rule to warn about new standard job creation Triggers when a new job.yml is created in src/deepwork/standard_jobs/ to remind that standard jobs ship with DeepWork and should only be created when explicitly requested (vs repository or library jobs). --- .deepwork/rules/new-standard-job-warning.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .deepwork/rules/new-standard-job-warning.md diff --git a/.deepwork/rules/new-standard-job-warning.md b/.deepwork/rules/new-standard-job-warning.md new file mode 100644 index 00000000..5895a7c9 --- /dev/null +++ b/.deepwork/rules/new-standard-job-warning.md @@ -0,0 +1,16 @@ +--- +name: New Standard Job Warning +created: src/deepwork/standard_jobs/*/job.yml +compare_to: prompt +--- +A new standard job is being created. Standard jobs are bundled with DeepWork and available to all users. + +**Before proceeding, verify this is intentional:** + +- **Standard jobs** (`src/deepwork/standard_jobs/`) - Ship with DeepWork, available globally +- **Repository jobs** (`.deepwork/jobs/`) - Specific to a single repository +- **Library jobs** - Installed from external packages + +Unless the user **explicitly requested** creating a new standard job (not just "a job" or "a new job"), this should likely be a **repository job** in `.deepwork/jobs/` instead. + +If uncertain, ask the user: "Should this be a standard job (shipped with DeepWork) or a repository-specific job?" From 4e2fb3a67d5ad8994cdf98f9608cc5714ea903ca Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Tue, 20 Jan 2026 11:15:44 -0700 Subject: [PATCH 3/4] Update new standard job warning for clarity Clarified that standard jobs will be auto-installed in any project using DeepWork. --- .deepwork/rules/new-standard-job-warning.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.deepwork/rules/new-standard-job-warning.md b/.deepwork/rules/new-standard-job-warning.md index 5895a7c9..e02495b4 100644 --- a/.deepwork/rules/new-standard-job-warning.md +++ b/.deepwork/rules/new-standard-job-warning.md @@ -3,11 +3,11 @@ name: New Standard Job Warning created: src/deepwork/standard_jobs/*/job.yml compare_to: prompt --- -A new standard job is being created. Standard jobs are bundled with DeepWork and available to all users. +A new standard job is being created. Standard jobs are bundled with DeepWork and will be installed in any project that uses DeepWork. **Before proceeding, verify this is intentional:** -- **Standard jobs** (`src/deepwork/standard_jobs/`) - Ship with DeepWork, available globally +- **Standard jobs** (`src/deepwork/standard_jobs/`) - Ship with DeepWork, auto-installed in all projects that use DeepWork - **Repository jobs** (`.deepwork/jobs/`) - Specific to a single repository - **Library jobs** - Installed from external packages From b27f27da18b5bdd40817040a7c82636cddea1471 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 18:32:18 +0000 Subject: [PATCH 4/4] Fix created mode tests for required compare_to field Update tests to include compare_to="base" after schema change made compare_to a required field. Also includes ruff formatting. --- src/deepwork/core/rules_parser.py | 4 +--- src/deepwork/hooks/rules_check.py | 9 ++++++++- tests/unit/test_rules_parser.py | 11 +++++++++++ uv.lock | 2 +- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/deepwork/core/rules_parser.py b/src/deepwork/core/rules_parser.py index 2d20edaa..04b1e3d2 100644 --- a/src/deepwork/core/rules_parser.py +++ b/src/deepwork/core/rules_parser.py @@ -119,9 +119,7 @@ def from_frontmatter( mode_count = sum([has_trigger, has_set, has_pair, has_created]) if mode_count == 0: - raise RulesParseError( - f"Rule '{name}' must have 'trigger', 'set', 'pair', or 'created'" - ) + raise RulesParseError(f"Rule '{name}' must have 'trigger', 'set', 'pair', or 'created'") if mode_count > 1: raise RulesParseError(f"Rule '{name}' has multiple detection modes - use only one") diff --git a/src/deepwork/hooks/rules_check.py b/src/deepwork/hooks/rules_check.py index 1e577a82..548bcee9 100644 --- a/src/deepwork/hooks/rules_check.py +++ b/src/deepwork/hooks/rules_check.py @@ -304,7 +304,14 @@ def get_created_files_default_tip() -> list[str]: committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() result = subprocess.run( - ["git", "diff", "--name-only", "--diff-filter=A", "--cached", f"origin/{default_branch}"], + [ + "git", + "diff", + "--name-only", + "--diff-filter=A", + "--cached", + f"origin/{default_branch}", + ], capture_output=True, text=True, check=False, diff --git a/tests/unit/test_rules_parser.py b/tests/unit/test_rules_parser.py index 50e8cc7c..ee8a2375 100644 --- a/tests/unit/test_rules_parser.py +++ b/tests/unit/test_rules_parser.py @@ -781,6 +781,7 @@ def test_fires_when_created_file_matches(self) -> None: detection_mode=DetectionMode.CREATED, created_patterns=["src/**/*.py"], instructions="Document the new module", + compare_to="base", ) created_files = ["src/new_module.py"] @@ -796,6 +797,7 @@ def test_does_not_fire_when_no_match(self) -> None: detection_mode=DetectionMode.CREATED, created_patterns=["src/**/*.py"], instructions="Document the new module", + compare_to="base", ) created_files = ["tests/test_new.py"] @@ -810,6 +812,7 @@ def test_does_not_fire_for_modified_files(self) -> None: detection_mode=DetectionMode.CREATED, created_patterns=["src/**/*.py"], instructions="Document the new module", + compare_to="base", ) # File is in changed_files but NOT in created_files changed_files = ["src/existing_module.py"] @@ -826,6 +829,7 @@ def test_multiple_created_patterns(self) -> None: detection_mode=DetectionMode.CREATED, created_patterns=["src/**/*.py", "lib/**/*.py"], instructions="Follow code standards", + compare_to="base", ) # Matches first pattern @@ -848,6 +852,7 @@ def test_created_with_nested_path(self) -> None: detection_mode=DetectionMode.CREATED, created_patterns=["src/components/**/*.tsx"], instructions="Document the component", + compare_to="base", ) created_files = ["src/components/ui/Button.tsx"] @@ -863,6 +868,7 @@ def test_created_mixed_with_changed(self) -> None: detection_mode=DetectionMode.CREATED, created_patterns=["src/**/*.py"], instructions="Document the new module", + compare_to="base", ) # src/existing.py is modified (in changed_files) # src/new.py is created (in created_files) @@ -884,6 +890,7 @@ def test_evaluate_rules_with_created_mode(self) -> None: triggers=["src/**/*.py"], safety=[], instructions="Check source", + compare_to="base", ), Rule( name="Created Rule", @@ -891,6 +898,7 @@ def test_evaluate_rules_with_created_mode(self) -> None: detection_mode=DetectionMode.CREATED, created_patterns=["src/**/*.py"], instructions="Document new files", + compare_to="base", ), ] # src/existing.py is modified, src/new.py is created @@ -919,6 +927,7 @@ def test_loads_rule_with_created_detection_mode(self, temp_dir: Path) -> None: """--- name: New Module Documentation created: src/**/*.py +compare_to: base --- A new Python module was created. Please add documentation. """ @@ -943,6 +952,7 @@ def test_loads_rule_with_multiple_created_patterns(self, temp_dir: Path) -> None created: - src/**/*.py - lib/**/*.py +compare_to: base --- New code must follow standards. """ @@ -965,6 +975,7 @@ def test_loads_created_rule_with_command_action(self, temp_dir: Path) -> None: """--- name: New File Lint created: "**/*.py" +compare_to: base action: command: "ruff check {file}" run_for: each_match diff --git a/uv.lock b/uv.lock index 564a99f6..c4091ca4 100644 --- a/uv.lock +++ b/uv.lock @@ -126,7 +126,7 @@ toml = [ [[package]] name = "deepwork" -version = "0.5.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "click" },