diff --git a/.deepwork/rules/new-standard-job-warning.md b/.deepwork/rules/new-standard-job-warning.md new file mode 100644 index 00000000..e02495b4 --- /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 will be installed in any project that uses DeepWork. + +**Before proceeding, verify this is intentional:** + +- **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 + +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?" diff --git a/doc/rules_syntax.md b/doc/rules_syntax.md index 7680bfd6..2ab86be1 100644 --- a/doc/rules_syntax.md +++ b/doc/rules_syntax.md @@ -99,6 +99,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: @@ -112,6 +127,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 @@ -206,6 +222,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) @@ -382,6 +439,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. @@ -517,6 +590,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: @@ -539,7 +645,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 c9bd6f00..04b1e3d2 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): @@ -77,6 +78,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 @@ -113,10 +115,11 @@ 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") @@ -126,6 +129,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 @@ -150,6 +154,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 @@ -178,6 +187,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, @@ -419,6 +429,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.""" @@ -429,13 +455,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 @@ -473,6 +504,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) @@ -480,6 +525,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. @@ -489,6 +535,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 @@ -505,7 +552,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..548bcee9 100644 --- a/src/deepwork/hooks/rules_check.py +++ b/src/deepwork/hooks/rules_check.py @@ -238,6 +238,156 @@ 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 +549,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 b9674eb0..bf091ab9 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", @@ -84,19 +89,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 cabb8e3f..ee8a2375 100644 --- a/tests/unit/test_rules_parser.py +++ b/tests/unit/test_rules_parser.py @@ -768,3 +768,228 @@ 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", + compare_to="base", + ) + 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", + compare_to="base", + ) + 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", + compare_to="base", + ) + # 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", + compare_to="base", + ) + + # 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", + compare_to="base", + ) + 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", + compare_to="base", + ) + # 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", + compare_to="base", + ), + Rule( + name="Created Rule", + filename="created-rule", + 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 + 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 +compare_to: base +--- +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 +compare_to: base +--- +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" +compare_to: base +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}" 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" },