deepwork 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. deepwork/cli/install.py +116 -71
  2. deepwork/cli/sync.py +20 -20
  3. deepwork/core/adapters.py +88 -51
  4. deepwork/core/command_executor.py +173 -0
  5. deepwork/core/generator.py +148 -31
  6. deepwork/core/hooks_syncer.py +51 -25
  7. deepwork/core/parser.py +8 -0
  8. deepwork/core/pattern_matcher.py +271 -0
  9. deepwork/core/rules_parser.py +559 -0
  10. deepwork/core/rules_queue.py +321 -0
  11. deepwork/hooks/README.md +181 -0
  12. deepwork/hooks/__init__.py +77 -1
  13. deepwork/hooks/claude_hook.sh +55 -0
  14. deepwork/hooks/gemini_hook.sh +55 -0
  15. deepwork/hooks/rules_check.py +700 -0
  16. deepwork/hooks/wrapper.py +363 -0
  17. deepwork/schemas/job_schema.py +14 -1
  18. deepwork/schemas/rules_schema.py +135 -0
  19. deepwork/standard_jobs/deepwork_jobs/job.yml +35 -53
  20. deepwork/standard_jobs/deepwork_jobs/steps/define.md +9 -6
  21. deepwork/standard_jobs/deepwork_jobs/steps/implement.md +28 -26
  22. deepwork/standard_jobs/deepwork_jobs/steps/learn.md +2 -2
  23. deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +30 -0
  24. deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml +8 -0
  25. deepwork/standard_jobs/deepwork_rules/job.yml +47 -0
  26. deepwork/standard_jobs/deepwork_rules/rules/.gitkeep +13 -0
  27. deepwork/standard_jobs/deepwork_rules/rules/api-documentation-sync.md.example +10 -0
  28. deepwork/standard_jobs/deepwork_rules/rules/readme-documentation.md.example +10 -0
  29. deepwork/standard_jobs/deepwork_rules/rules/security-review.md.example +11 -0
  30. deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md +46 -0
  31. deepwork/standard_jobs/deepwork_rules/rules/source-test-pairing.md.example +13 -0
  32. deepwork/standard_jobs/deepwork_rules/steps/define.md +249 -0
  33. deepwork/templates/claude/skill-job-meta.md.jinja +70 -0
  34. deepwork/templates/claude/skill-job-step.md.jinja +198 -0
  35. deepwork/templates/gemini/skill-job-meta.toml.jinja +76 -0
  36. deepwork/templates/gemini/skill-job-step.toml.jinja +147 -0
  37. {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/METADATA +56 -25
  38. deepwork-0.3.1.dist-info/RECORD +62 -0
  39. deepwork/core/policy_parser.py +0 -295
  40. deepwork/hooks/evaluate_policies.py +0 -376
  41. deepwork/schemas/policy_schema.py +0 -78
  42. deepwork/standard_jobs/deepwork_policy/hooks/capture_prompt_work_tree.sh +0 -27
  43. deepwork/standard_jobs/deepwork_policy/hooks/global_hooks.yml +0 -8
  44. deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh +0 -56
  45. deepwork/standard_jobs/deepwork_policy/job.yml +0 -35
  46. deepwork/standard_jobs/deepwork_policy/steps/define.md +0 -195
  47. deepwork/templates/claude/command-job-step.md.jinja +0 -210
  48. deepwork/templates/default_policy.yml +0 -53
  49. deepwork/templates/gemini/command-job-step.toml.jinja +0 -169
  50. deepwork-0.2.0.dist-info/RECORD +0 -49
  51. /deepwork/standard_jobs/{deepwork_policy → deepwork_rules}/hooks/user_prompt_submit.sh +0 -0
  52. {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/WHEEL +0 -0
  53. {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/entry_points.txt +0 -0
  54. {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/licenses/LICENSE.md +0 -0
@@ -0,0 +1,559 @@
1
+ """Rule definition parser (v2 - frontmatter markdown format)."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from deepwork.core.pattern_matcher import (
11
+ has_variables,
12
+ match_pattern,
13
+ matches_any_pattern,
14
+ resolve_pattern,
15
+ )
16
+ from deepwork.schemas.rules_schema import RULES_FRONTMATTER_SCHEMA
17
+ from deepwork.utils.validation import ValidationError, validate_against_schema
18
+
19
+
20
+ class RulesParseError(Exception):
21
+ """Exception raised for rule parsing errors."""
22
+
23
+ pass
24
+
25
+
26
+ class DetectionMode(Enum):
27
+ """How the rule detects when to fire."""
28
+
29
+ TRIGGER_SAFETY = "trigger_safety" # Fire when trigger matches, safety doesn't
30
+ SET = "set" # Bidirectional file correspondence
31
+ PAIR = "pair" # Directional file correspondence
32
+ CREATED = "created" # Fire when created files match patterns
33
+
34
+
35
+ class ActionType(Enum):
36
+ """What happens when the rule fires."""
37
+
38
+ PROMPT = "prompt" # Show instructions to agent (default)
39
+ COMMAND = "command" # Run an idempotent command
40
+
41
+
42
+ # Valid compare_to values
43
+ COMPARE_TO_VALUES = frozenset({"base", "default_tip", "prompt"})
44
+
45
+
46
+ @dataclass
47
+ class CommandAction:
48
+ """Configuration for command action."""
49
+
50
+ command: str # Command template (supports {file}, {files}, {repo_root})
51
+ run_for: str = "each_match" # "each_match" or "all_matches"
52
+
53
+
54
+ @dataclass
55
+ class PairConfig:
56
+ """Configuration for pair detection mode."""
57
+
58
+ trigger: str # Pattern that triggers
59
+ expects: list[str] # Patterns for expected corresponding files
60
+
61
+
62
+ @dataclass
63
+ class Rule:
64
+ """Represents a single rule definition (v2 format)."""
65
+
66
+ # Identity
67
+ name: str # Human-friendly name (displayed in promise tags)
68
+ filename: str # Filename without .md extension (used for queue)
69
+
70
+ # Detection mode (exactly one must be set)
71
+ detection_mode: DetectionMode
72
+
73
+ # Common options (required)
74
+ compare_to: str # Required: "base", "default_tip", or "prompt"
75
+
76
+ # Detection mode details (optional, depends on mode)
77
+ triggers: list[str] = field(default_factory=list) # For TRIGGER_SAFETY mode
78
+ safety: list[str] = field(default_factory=list) # For TRIGGER_SAFETY mode
79
+ set_patterns: list[str] = field(default_factory=list) # For SET mode
80
+ pair_config: PairConfig | None = None # For PAIR mode
81
+ created_patterns: list[str] = field(default_factory=list) # For CREATED mode
82
+
83
+ # Action type
84
+ action_type: ActionType = ActionType.PROMPT
85
+ instructions: str = "" # For PROMPT action (markdown body)
86
+ command_action: CommandAction | None = None # For COMMAND action
87
+
88
+ @classmethod
89
+ def from_frontmatter(
90
+ cls,
91
+ frontmatter: dict[str, Any],
92
+ markdown_body: str,
93
+ filename: str,
94
+ ) -> "Rule":
95
+ """
96
+ Create Rule from parsed frontmatter and markdown body.
97
+
98
+ Args:
99
+ frontmatter: Parsed YAML frontmatter
100
+ markdown_body: Markdown content after frontmatter
101
+ filename: Filename without .md extension
102
+
103
+ Returns:
104
+ Rule instance
105
+
106
+ Raises:
107
+ RulesParseError: If validation fails
108
+ """
109
+ # Get name (required)
110
+ name = frontmatter.get("name", "")
111
+ if not name:
112
+ raise RulesParseError(f"Rule '{filename}' missing required 'name' field")
113
+
114
+ # Determine detection mode
115
+ has_trigger = "trigger" in frontmatter
116
+ has_set = "set" in frontmatter
117
+ has_pair = "pair" in frontmatter
118
+ has_created = "created" in frontmatter
119
+
120
+ mode_count = sum([has_trigger, has_set, has_pair, has_created])
121
+ if mode_count == 0:
122
+ raise RulesParseError(f"Rule '{name}' must have 'trigger', 'set', 'pair', or 'created'")
123
+ if mode_count > 1:
124
+ raise RulesParseError(f"Rule '{name}' has multiple detection modes - use only one")
125
+
126
+ # Parse based on detection mode
127
+ detection_mode: DetectionMode
128
+ triggers: list[str] = []
129
+ safety: list[str] = []
130
+ set_patterns: list[str] = []
131
+ pair_config: PairConfig | None = None
132
+ created_patterns: list[str] = []
133
+
134
+ if has_trigger:
135
+ detection_mode = DetectionMode.TRIGGER_SAFETY
136
+ trigger = frontmatter["trigger"]
137
+ triggers = [trigger] if isinstance(trigger, str) else list(trigger)
138
+ safety_data = frontmatter.get("safety", [])
139
+ safety = [safety_data] if isinstance(safety_data, str) else list(safety_data)
140
+
141
+ elif has_set:
142
+ detection_mode = DetectionMode.SET
143
+ set_patterns = list(frontmatter["set"])
144
+ if len(set_patterns) < 2:
145
+ raise RulesParseError(f"Rule '{name}' set requires at least 2 patterns")
146
+
147
+ elif has_pair:
148
+ detection_mode = DetectionMode.PAIR
149
+ pair_data = frontmatter["pair"]
150
+ expects = pair_data["expects"]
151
+ expects_list = [expects] if isinstance(expects, str) else list(expects)
152
+ pair_config = PairConfig(
153
+ trigger=pair_data["trigger"],
154
+ expects=expects_list,
155
+ )
156
+
157
+ elif has_created:
158
+ detection_mode = DetectionMode.CREATED
159
+ created = frontmatter["created"]
160
+ created_patterns = [created] if isinstance(created, str) else list(created)
161
+
162
+ # Determine action type
163
+ action_type: ActionType
164
+ command_action: CommandAction | None = None
165
+
166
+ if "action" in frontmatter:
167
+ action_type = ActionType.COMMAND
168
+ action_data = frontmatter["action"]
169
+ command_action = CommandAction(
170
+ command=action_data["command"],
171
+ run_for=action_data.get("run_for", "each_match"),
172
+ )
173
+ else:
174
+ action_type = ActionType.PROMPT
175
+ # Markdown body is the instructions
176
+ if not markdown_body.strip():
177
+ raise RulesParseError(f"Rule '{name}' with prompt action requires markdown body")
178
+
179
+ # Get compare_to (required field)
180
+ compare_to = frontmatter["compare_to"]
181
+
182
+ return cls(
183
+ name=name,
184
+ filename=filename,
185
+ detection_mode=detection_mode,
186
+ triggers=triggers,
187
+ safety=safety,
188
+ set_patterns=set_patterns,
189
+ pair_config=pair_config,
190
+ created_patterns=created_patterns,
191
+ action_type=action_type,
192
+ instructions=markdown_body.strip(),
193
+ command_action=command_action,
194
+ compare_to=compare_to,
195
+ )
196
+
197
+
198
+ def parse_frontmatter_file(filepath: Path) -> tuple[dict[str, Any], str]:
199
+ """
200
+ Parse a markdown file with YAML frontmatter.
201
+
202
+ Args:
203
+ filepath: Path to .md file
204
+
205
+ Returns:
206
+ Tuple of (frontmatter_dict, markdown_body)
207
+
208
+ Raises:
209
+ RulesParseError: If parsing fails
210
+ """
211
+ try:
212
+ content = filepath.read_text(encoding="utf-8")
213
+ except OSError as e:
214
+ raise RulesParseError(f"Failed to read rule file: {e}") from e
215
+
216
+ # Split frontmatter from body
217
+ if not content.startswith("---"):
218
+ raise RulesParseError(
219
+ f"Rule file '{filepath.name}' must start with '---' frontmatter delimiter"
220
+ )
221
+
222
+ # Find end of frontmatter
223
+ end_marker = content.find("\n---", 3)
224
+ if end_marker == -1:
225
+ raise RulesParseError(
226
+ f"Rule file '{filepath.name}' missing closing '---' frontmatter delimiter"
227
+ )
228
+
229
+ frontmatter_str = content[4:end_marker] # Skip initial "---\n"
230
+ markdown_body = content[end_marker + 4 :] # Skip "\n---\n" or "\n---"
231
+
232
+ # Parse YAML frontmatter
233
+ try:
234
+ frontmatter = yaml.safe_load(frontmatter_str)
235
+ except yaml.YAMLError as e:
236
+ raise RulesParseError(f"Invalid YAML frontmatter in '{filepath.name}': {e}") from e
237
+
238
+ if frontmatter is None:
239
+ frontmatter = {}
240
+
241
+ if not isinstance(frontmatter, dict):
242
+ raise RulesParseError(
243
+ f"Frontmatter in '{filepath.name}' must be a mapping, got {type(frontmatter).__name__}"
244
+ )
245
+
246
+ return frontmatter, markdown_body
247
+
248
+
249
+ def parse_rule_file(filepath: Path) -> Rule:
250
+ """
251
+ Parse a single rule from a frontmatter markdown file.
252
+
253
+ Args:
254
+ filepath: Path to .md file in .deepwork/rules/
255
+
256
+ Returns:
257
+ Parsed Rule object
258
+
259
+ Raises:
260
+ RulesParseError: If parsing or validation fails
261
+ """
262
+ if not filepath.exists():
263
+ raise RulesParseError(f"Rule file does not exist: {filepath}")
264
+
265
+ if not filepath.is_file():
266
+ raise RulesParseError(f"Rule path is not a file: {filepath}")
267
+
268
+ frontmatter, markdown_body = parse_frontmatter_file(filepath)
269
+
270
+ # Validate against schema
271
+ try:
272
+ validate_against_schema(frontmatter, RULES_FRONTMATTER_SCHEMA)
273
+ except ValidationError as e:
274
+ raise RulesParseError(f"Rule '{filepath.name}' validation failed: {e}") from e
275
+
276
+ # Create Rule object
277
+ filename = filepath.stem # filename without .md extension
278
+ return Rule.from_frontmatter(frontmatter, markdown_body, filename)
279
+
280
+
281
+ def load_rules_from_directory(rules_dir: Path) -> list[Rule]:
282
+ """
283
+ Load all rules from a directory.
284
+
285
+ Args:
286
+ rules_dir: Path to .deepwork/rules/ directory
287
+
288
+ Returns:
289
+ List of parsed Rule objects (sorted by filename)
290
+
291
+ Raises:
292
+ RulesParseError: If any rule file fails to parse
293
+ """
294
+ if not rules_dir.exists():
295
+ return []
296
+
297
+ if not rules_dir.is_dir():
298
+ raise RulesParseError(f"Rules path is not a directory: {rules_dir}")
299
+
300
+ rules = []
301
+ for filepath in sorted(rules_dir.glob("*.md")):
302
+ rule = parse_rule_file(filepath)
303
+ rules.append(rule)
304
+
305
+ return rules
306
+
307
+
308
+ # =============================================================================
309
+ # Evaluation Logic
310
+ # =============================================================================
311
+
312
+
313
+ def evaluate_trigger_safety(
314
+ rule: Rule,
315
+ changed_files: list[str],
316
+ ) -> bool:
317
+ """
318
+ Evaluate a trigger/safety mode rule.
319
+
320
+ Returns True if rule should fire:
321
+ - At least one changed file matches a trigger pattern
322
+ - AND no changed file matches a safety pattern
323
+ """
324
+ # Check if any trigger matches
325
+ trigger_matched = False
326
+ for file_path in changed_files:
327
+ if matches_any_pattern(file_path, rule.triggers):
328
+ trigger_matched = True
329
+ break
330
+
331
+ if not trigger_matched:
332
+ return False
333
+
334
+ # Check if any safety pattern matches
335
+ if rule.safety:
336
+ for file_path in changed_files:
337
+ if matches_any_pattern(file_path, rule.safety):
338
+ return False
339
+
340
+ return True
341
+
342
+
343
+ def evaluate_set_correspondence(
344
+ rule: Rule,
345
+ changed_files: list[str],
346
+ ) -> tuple[bool, list[str], list[str]]:
347
+ """
348
+ Evaluate a set (bidirectional correspondence) rule.
349
+
350
+ Returns:
351
+ Tuple of (should_fire, trigger_files, missing_files)
352
+ - should_fire: True if correspondence is incomplete
353
+ - trigger_files: Files that triggered (matched a pattern)
354
+ - missing_files: Expected files that didn't change
355
+ """
356
+ trigger_files: list[str] = []
357
+ missing_files: list[str] = []
358
+ changed_set = set(changed_files)
359
+
360
+ for file_path in changed_files:
361
+ # Check each pattern in the set
362
+ for pattern in rule.set_patterns:
363
+ result = match_pattern(pattern, file_path)
364
+ if result.matched:
365
+ trigger_files.append(file_path)
366
+
367
+ # Check if all other corresponding files also changed
368
+ for other_pattern in rule.set_patterns:
369
+ if other_pattern == pattern:
370
+ continue
371
+
372
+ if has_variables(other_pattern):
373
+ expected = resolve_pattern(other_pattern, result.variables)
374
+ else:
375
+ expected = other_pattern
376
+
377
+ if expected not in changed_set:
378
+ if expected not in missing_files:
379
+ missing_files.append(expected)
380
+
381
+ break # Only match one pattern per file
382
+
383
+ # Rule fires if there are trigger files with missing correspondences
384
+ should_fire = len(trigger_files) > 0 and len(missing_files) > 0
385
+ return should_fire, trigger_files, missing_files
386
+
387
+
388
+ def evaluate_pair_correspondence(
389
+ rule: Rule,
390
+ changed_files: list[str],
391
+ ) -> tuple[bool, list[str], list[str]]:
392
+ """
393
+ Evaluate a pair (directional correspondence) rule.
394
+
395
+ Only trigger-side changes require corresponding expected files.
396
+ Expected-side changes alone do not trigger.
397
+
398
+ Returns:
399
+ Tuple of (should_fire, trigger_files, missing_files)
400
+ """
401
+ if rule.pair_config is None:
402
+ return False, [], []
403
+
404
+ trigger_files: list[str] = []
405
+ missing_files: list[str] = []
406
+ changed_set = set(changed_files)
407
+
408
+ trigger_pattern = rule.pair_config.trigger
409
+ expects_patterns = rule.pair_config.expects
410
+
411
+ for file_path in changed_files:
412
+ # Only check trigger pattern (directional)
413
+ result = match_pattern(trigger_pattern, file_path)
414
+ if result.matched:
415
+ trigger_files.append(file_path)
416
+
417
+ # Check if all expected files also changed
418
+ for expects_pattern in expects_patterns:
419
+ if has_variables(expects_pattern):
420
+ expected = resolve_pattern(expects_pattern, result.variables)
421
+ else:
422
+ expected = expects_pattern
423
+
424
+ if expected not in changed_set:
425
+ if expected not in missing_files:
426
+ missing_files.append(expected)
427
+
428
+ should_fire = len(trigger_files) > 0 and len(missing_files) > 0
429
+ return should_fire, trigger_files, missing_files
430
+
431
+
432
+ def evaluate_created(
433
+ rule: Rule,
434
+ created_files: list[str],
435
+ ) -> bool:
436
+ """
437
+ Evaluate a created mode rule.
438
+
439
+ Returns True if rule should fire:
440
+ - At least one created file matches a created pattern
441
+ """
442
+ for file_path in created_files:
443
+ if matches_any_pattern(file_path, rule.created_patterns):
444
+ return True
445
+ return False
446
+
447
+
448
+ @dataclass
449
+ class RuleEvaluationResult:
450
+ """Result of evaluating a single rule."""
451
+
452
+ rule: Rule
453
+ should_fire: bool
454
+ trigger_files: list[str] = field(default_factory=list)
455
+ missing_files: list[str] = field(default_factory=list) # For set/pair modes
456
+
457
+
458
+ def evaluate_rule(
459
+ rule: Rule,
460
+ changed_files: list[str],
461
+ created_files: list[str] | None = None,
462
+ ) -> RuleEvaluationResult:
463
+ """
464
+ Evaluate whether a rule should fire based on changed files.
465
+
466
+ Args:
467
+ rule: Rule to evaluate
468
+ changed_files: List of changed file paths (relative)
469
+ created_files: List of newly created file paths (relative), for CREATED mode
470
+
471
+ Returns:
472
+ RuleEvaluationResult with evaluation details
473
+ """
474
+ if rule.detection_mode == DetectionMode.TRIGGER_SAFETY:
475
+ should_fire = evaluate_trigger_safety(rule, changed_files)
476
+ trigger_files = (
477
+ [f for f in changed_files if matches_any_pattern(f, rule.triggers)]
478
+ if should_fire
479
+ else []
480
+ )
481
+ return RuleEvaluationResult(
482
+ rule=rule,
483
+ should_fire=should_fire,
484
+ trigger_files=trigger_files,
485
+ )
486
+
487
+ elif rule.detection_mode == DetectionMode.SET:
488
+ should_fire, trigger_files, missing_files = evaluate_set_correspondence(rule, changed_files)
489
+ return RuleEvaluationResult(
490
+ rule=rule,
491
+ should_fire=should_fire,
492
+ trigger_files=trigger_files,
493
+ missing_files=missing_files,
494
+ )
495
+
496
+ elif rule.detection_mode == DetectionMode.PAIR:
497
+ should_fire, trigger_files, missing_files = evaluate_pair_correspondence(
498
+ rule, changed_files
499
+ )
500
+ return RuleEvaluationResult(
501
+ rule=rule,
502
+ should_fire=should_fire,
503
+ trigger_files=trigger_files,
504
+ missing_files=missing_files,
505
+ )
506
+
507
+ elif rule.detection_mode == DetectionMode.CREATED:
508
+ files_to_check = created_files if created_files is not None else []
509
+ should_fire = evaluate_created(rule, files_to_check)
510
+ trigger_files = (
511
+ [f for f in files_to_check if matches_any_pattern(f, rule.created_patterns)]
512
+ if should_fire
513
+ else []
514
+ )
515
+ return RuleEvaluationResult(
516
+ rule=rule,
517
+ should_fire=should_fire,
518
+ trigger_files=trigger_files,
519
+ )
520
+
521
+ return RuleEvaluationResult(rule=rule, should_fire=False)
522
+
523
+
524
+ def evaluate_rules(
525
+ rules: list[Rule],
526
+ changed_files: list[str],
527
+ promised_rules: set[str] | None = None,
528
+ created_files: list[str] | None = None,
529
+ ) -> list[RuleEvaluationResult]:
530
+ """
531
+ Evaluate which rules should fire.
532
+
533
+ Args:
534
+ rules: List of rules to evaluate
535
+ changed_files: List of changed file paths (relative)
536
+ promised_rules: Set of rule names that have been marked as addressed
537
+ via <promise> tags (case-insensitive)
538
+ created_files: List of newly created file paths (relative), for CREATED mode
539
+
540
+ Returns:
541
+ List of RuleEvaluationResult for rules that should fire
542
+ """
543
+ if promised_rules is None:
544
+ promised_rules = set()
545
+
546
+ # Normalize promised names for case-insensitive comparison
547
+ promised_lower = {name.lower() for name in promised_rules}
548
+
549
+ results = []
550
+ for rule in rules:
551
+ # Skip if already promised/addressed (case-insensitive)
552
+ if rule.name.lower() in promised_lower:
553
+ continue
554
+
555
+ result = evaluate_rule(rule, changed_files, created_files)
556
+ if result.should_fire:
557
+ results.append(result)
558
+
559
+ return results