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