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,514 @@
1
+ """
2
+ Rules check hook for DeepWork (v2).
3
+
4
+ This hook evaluates rules when the agent finishes (after_agent event).
5
+ It uses the wrapper system for cross-platform compatibility.
6
+
7
+ Rule files are loaded from .deepwork/rules/ directory as frontmatter markdown files.
8
+
9
+ Usage (via shell wrapper):
10
+ claude_hook.sh deepwork.hooks.rules_check
11
+ gemini_hook.sh deepwork.hooks.rules_check
12
+
13
+ Or directly with platform environment variable:
14
+ DEEPWORK_HOOK_PLATFORM=claude python -m deepwork.hooks.rules_check
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from deepwork.core.command_executor import (
27
+ all_commands_succeeded,
28
+ format_command_errors,
29
+ run_command_action,
30
+ )
31
+ from deepwork.core.rules_parser import (
32
+ ActionType,
33
+ DetectionMode,
34
+ Rule,
35
+ RuleEvaluationResult,
36
+ RulesParseError,
37
+ evaluate_rules,
38
+ load_rules_from_directory,
39
+ )
40
+ from deepwork.core.rules_queue import (
41
+ ActionResult,
42
+ QueueEntryStatus,
43
+ RulesQueue,
44
+ compute_trigger_hash,
45
+ )
46
+ from deepwork.hooks.wrapper import (
47
+ HookInput,
48
+ HookOutput,
49
+ NormalizedEvent,
50
+ Platform,
51
+ run_hook,
52
+ )
53
+
54
+
55
+ def get_default_branch() -> str:
56
+ """Get the default branch name (main or master)."""
57
+ try:
58
+ result = subprocess.run(
59
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
60
+ capture_output=True,
61
+ text=True,
62
+ check=True,
63
+ )
64
+ return result.stdout.strip().split("/")[-1]
65
+ except subprocess.CalledProcessError:
66
+ pass
67
+
68
+ for branch in ["main", "master"]:
69
+ try:
70
+ subprocess.run(
71
+ ["git", "rev-parse", "--verify", f"origin/{branch}"],
72
+ capture_output=True,
73
+ check=True,
74
+ )
75
+ return branch
76
+ except subprocess.CalledProcessError:
77
+ continue
78
+
79
+ return "main"
80
+
81
+
82
+ def get_baseline_ref(mode: str) -> str:
83
+ """Get the baseline reference for a compare_to mode."""
84
+ if mode == "base":
85
+ try:
86
+ default_branch = get_default_branch()
87
+ result = subprocess.run(
88
+ ["git", "merge-base", "HEAD", f"origin/{default_branch}"],
89
+ capture_output=True,
90
+ text=True,
91
+ check=True,
92
+ )
93
+ return result.stdout.strip()
94
+ except subprocess.CalledProcessError:
95
+ return "base"
96
+ elif mode == "default_tip":
97
+ try:
98
+ default_branch = get_default_branch()
99
+ result = subprocess.run(
100
+ ["git", "rev-parse", f"origin/{default_branch}"],
101
+ capture_output=True,
102
+ text=True,
103
+ check=True,
104
+ )
105
+ return result.stdout.strip()
106
+ except subprocess.CalledProcessError:
107
+ return "default_tip"
108
+ elif mode == "prompt":
109
+ baseline_path = Path(".deepwork/.last_work_tree")
110
+ if baseline_path.exists():
111
+ # Use file modification time as reference
112
+ return str(int(baseline_path.stat().st_mtime))
113
+ return "prompt"
114
+ return mode
115
+
116
+
117
+ def get_changed_files_base() -> list[str]:
118
+ """Get files changed relative to branch base."""
119
+ default_branch = get_default_branch()
120
+
121
+ try:
122
+ result = subprocess.run(
123
+ ["git", "merge-base", "HEAD", f"origin/{default_branch}"],
124
+ capture_output=True,
125
+ text=True,
126
+ check=True,
127
+ )
128
+ merge_base = result.stdout.strip()
129
+
130
+ subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
131
+
132
+ result = subprocess.run(
133
+ ["git", "diff", "--name-only", merge_base, "HEAD"],
134
+ capture_output=True,
135
+ text=True,
136
+ check=True,
137
+ )
138
+ committed_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
139
+
140
+ result = subprocess.run(
141
+ ["git", "diff", "--name-only", "--cached"],
142
+ capture_output=True,
143
+ text=True,
144
+ check=False,
145
+ )
146
+ staged_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
147
+
148
+ result = subprocess.run(
149
+ ["git", "ls-files", "--others", "--exclude-standard"],
150
+ capture_output=True,
151
+ text=True,
152
+ check=False,
153
+ )
154
+ untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
155
+
156
+ all_files = committed_files | staged_files | untracked_files
157
+ return sorted([f for f in all_files if f])
158
+
159
+ except subprocess.CalledProcessError:
160
+ return []
161
+
162
+
163
+ def get_changed_files_default_tip() -> list[str]:
164
+ """Get files changed compared to default branch tip."""
165
+ default_branch = get_default_branch()
166
+
167
+ try:
168
+ subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
169
+
170
+ result = subprocess.run(
171
+ ["git", "diff", "--name-only", f"origin/{default_branch}..HEAD"],
172
+ capture_output=True,
173
+ text=True,
174
+ check=True,
175
+ )
176
+ committed_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
177
+
178
+ result = subprocess.run(
179
+ ["git", "diff", "--name-only", "--cached"],
180
+ capture_output=True,
181
+ text=True,
182
+ check=False,
183
+ )
184
+ staged_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
185
+
186
+ result = subprocess.run(
187
+ ["git", "ls-files", "--others", "--exclude-standard"],
188
+ capture_output=True,
189
+ text=True,
190
+ check=False,
191
+ )
192
+ untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
193
+
194
+ all_files = committed_files | staged_files | untracked_files
195
+ return sorted([f for f in all_files if f])
196
+
197
+ except subprocess.CalledProcessError:
198
+ return []
199
+
200
+
201
+ def get_changed_files_prompt() -> list[str]:
202
+ """Get files changed since prompt was submitted."""
203
+ baseline_path = Path(".deepwork/.last_work_tree")
204
+
205
+ try:
206
+ subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
207
+
208
+ result = subprocess.run(
209
+ ["git", "diff", "--name-only", "--cached"],
210
+ capture_output=True,
211
+ text=True,
212
+ check=False,
213
+ )
214
+ current_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
215
+ current_files = {f for f in current_files if f}
216
+
217
+ if baseline_path.exists():
218
+ baseline_files = set(baseline_path.read_text().strip().split("\n"))
219
+ baseline_files = {f for f in baseline_files if f}
220
+ new_files = current_files - baseline_files
221
+ return sorted(new_files)
222
+ else:
223
+ return sorted(current_files)
224
+
225
+ except (subprocess.CalledProcessError, OSError):
226
+ return []
227
+
228
+
229
+ def get_changed_files_for_mode(mode: str) -> list[str]:
230
+ """Get changed files for a specific compare_to mode."""
231
+ if mode == "base":
232
+ return get_changed_files_base()
233
+ elif mode == "default_tip":
234
+ return get_changed_files_default_tip()
235
+ elif mode == "prompt":
236
+ return get_changed_files_prompt()
237
+ else:
238
+ return get_changed_files_base()
239
+
240
+
241
+ def extract_promise_tags(text: str) -> set[str]:
242
+ """
243
+ Extract rule names from <promise> tags in text.
244
+
245
+ Supports both:
246
+ - <promise>Rule Name</promise>
247
+ - <promise>✓ Rule Name</promise>
248
+ """
249
+ # Match with optional checkmark prefix (✓ or ✓ with space)
250
+ pattern = r"<promise>(?:\s*)?(?:✓\s*)?([^<]+)</promise>"
251
+ matches = re.findall(pattern, text, re.IGNORECASE | re.DOTALL)
252
+ return {m.strip() for m in matches}
253
+
254
+
255
+ def extract_conversation_from_transcript(transcript_path: str, platform: Platform) -> str:
256
+ """
257
+ Extract conversation text from a transcript file.
258
+
259
+ Handles platform-specific transcript formats.
260
+ """
261
+ if not transcript_path or not Path(transcript_path).exists():
262
+ return ""
263
+
264
+ try:
265
+ content = Path(transcript_path).read_text()
266
+
267
+ if platform == Platform.CLAUDE:
268
+ # Claude uses JSONL format - each line is a JSON object
269
+ conversation_parts = []
270
+ for line in content.strip().split("\n"):
271
+ if not line.strip():
272
+ continue
273
+ try:
274
+ entry = json.loads(line)
275
+ if entry.get("role") == "assistant":
276
+ message_content = entry.get("message", {}).get("content", [])
277
+ for part in message_content:
278
+ if part.get("type") == "text":
279
+ conversation_parts.append(part.get("text", ""))
280
+ except json.JSONDecodeError:
281
+ continue
282
+ return "\n".join(conversation_parts)
283
+
284
+ elif platform == Platform.GEMINI:
285
+ # Gemini uses JSON format
286
+ try:
287
+ data = json.loads(content)
288
+ # Extract text from messages
289
+ conversation_parts = []
290
+ messages = data.get("messages", [])
291
+ for msg in messages:
292
+ if msg.get("role") == "model":
293
+ parts = msg.get("parts", [])
294
+ for part in parts:
295
+ if isinstance(part, dict) and "text" in part:
296
+ conversation_parts.append(part["text"])
297
+ elif isinstance(part, str):
298
+ conversation_parts.append(part)
299
+ return "\n".join(conversation_parts)
300
+ except json.JSONDecodeError:
301
+ return ""
302
+
303
+ return ""
304
+ except Exception:
305
+ return ""
306
+
307
+
308
+ def format_rules_message(results: list[RuleEvaluationResult]) -> str:
309
+ """
310
+ Format triggered rules into a concise message for the agent.
311
+
312
+ Groups rules by name and uses minimal formatting.
313
+ """
314
+ lines = ["## DeepWork Rules Triggered", ""]
315
+ lines.append(
316
+ "Comply with the following rules. "
317
+ "To mark a rule as addressed, include `<promise>Rule Name</promise>` "
318
+ "in your response."
319
+ )
320
+ lines.append("")
321
+
322
+ # Group results by rule name
323
+ by_name: dict[str, list[RuleEvaluationResult]] = {}
324
+ for result in results:
325
+ name = result.rule.name
326
+ if name not in by_name:
327
+ by_name[name] = []
328
+ by_name[name].append(result)
329
+
330
+ for name, rule_results in by_name.items():
331
+ rule = rule_results[0].rule
332
+ lines.append(f"## {name}")
333
+ lines.append("")
334
+
335
+ # For set/pair modes, show the correspondence violations concisely
336
+ if rule.detection_mode in (DetectionMode.SET, DetectionMode.PAIR):
337
+ for result in rule_results:
338
+ for trigger_file in result.trigger_files:
339
+ for missing_file in result.missing_files:
340
+ lines.append(f"{trigger_file} -> {missing_file}")
341
+ lines.append("")
342
+
343
+ # Show instructions
344
+ if rule.instructions:
345
+ lines.append(rule.instructions.strip())
346
+ lines.append("")
347
+
348
+ return "\n".join(lines)
349
+
350
+
351
+ def rules_check_hook(hook_input: HookInput) -> HookOutput:
352
+ """
353
+ Main hook logic for rules evaluation (v2).
354
+
355
+ This is called for after_agent events to check if rules need attention
356
+ before allowing the agent to complete.
357
+ """
358
+ # Only process after_agent events
359
+ if hook_input.event != NormalizedEvent.AFTER_AGENT:
360
+ return HookOutput()
361
+
362
+ # Check if rules directory exists
363
+ rules_dir = Path(".deepwork/rules")
364
+ if not rules_dir.exists():
365
+ return HookOutput()
366
+
367
+ # Extract conversation context from transcript
368
+ conversation_context = extract_conversation_from_transcript(
369
+ hook_input.transcript_path, hook_input.platform
370
+ )
371
+
372
+ # Extract promise tags (case-insensitive)
373
+ promised_rules = extract_promise_tags(conversation_context)
374
+
375
+ # Load rules
376
+ try:
377
+ rules = load_rules_from_directory(rules_dir)
378
+ except RulesParseError as e:
379
+ print(f"Error loading rules: {e}", file=sys.stderr)
380
+ return HookOutput()
381
+
382
+ if not rules:
383
+ return HookOutput()
384
+
385
+ # Initialize queue
386
+ queue = RulesQueue()
387
+
388
+ # Group rules by compare_to mode
389
+ rules_by_mode: dict[str, list[Rule]] = {}
390
+ for rule in rules:
391
+ mode = rule.compare_to
392
+ if mode not in rules_by_mode:
393
+ rules_by_mode[mode] = []
394
+ rules_by_mode[mode].append(rule)
395
+
396
+ # Evaluate rules and collect results
397
+ prompt_results: list[RuleEvaluationResult] = []
398
+ command_errors: list[str] = []
399
+
400
+ for mode, mode_rules in rules_by_mode.items():
401
+ changed_files = get_changed_files_for_mode(mode)
402
+ if not changed_files:
403
+ continue
404
+
405
+ baseline_ref = get_baseline_ref(mode)
406
+
407
+ # Evaluate which rules fire
408
+ results = evaluate_rules(mode_rules, changed_files, promised_rules)
409
+
410
+ for result in results:
411
+ rule = result.rule
412
+
413
+ # Compute trigger hash for queue deduplication
414
+ trigger_hash = compute_trigger_hash(
415
+ rule.name,
416
+ result.trigger_files,
417
+ baseline_ref,
418
+ )
419
+
420
+ # Check if already in queue (passed/skipped)
421
+ existing = queue.get_entry(trigger_hash)
422
+ if existing and existing.status in (
423
+ QueueEntryStatus.PASSED,
424
+ QueueEntryStatus.SKIPPED,
425
+ ):
426
+ continue
427
+
428
+ # Create queue entry if new
429
+ if not existing:
430
+ queue.create_entry(
431
+ rule_name=rule.name,
432
+ rule_file=f"{rule.filename}.md",
433
+ trigger_files=result.trigger_files,
434
+ baseline_ref=baseline_ref,
435
+ expected_files=result.missing_files,
436
+ )
437
+
438
+ # Handle based on action type
439
+ if rule.action_type == ActionType.COMMAND:
440
+ # Run command action
441
+ if rule.command_action:
442
+ repo_root = Path.cwd()
443
+ cmd_results = run_command_action(
444
+ rule.command_action,
445
+ result.trigger_files,
446
+ repo_root,
447
+ )
448
+
449
+ if all_commands_succeeded(cmd_results):
450
+ # Command succeeded, mark as passed
451
+ queue.update_status(
452
+ trigger_hash,
453
+ QueueEntryStatus.PASSED,
454
+ ActionResult(
455
+ type="command",
456
+ output=cmd_results[0].stdout if cmd_results else None,
457
+ exit_code=0,
458
+ ),
459
+ )
460
+ else:
461
+ # Command failed
462
+ error_msg = format_command_errors(cmd_results)
463
+ skip_hint = f"To skip, include `<promise>✓ {rule.name}</promise>` in your response.\n"
464
+ command_errors.append(f"## {rule.name}\n{error_msg}{skip_hint}")
465
+ queue.update_status(
466
+ trigger_hash,
467
+ QueueEntryStatus.FAILED,
468
+ ActionResult(
469
+ type="command",
470
+ output=error_msg,
471
+ exit_code=cmd_results[0].exit_code if cmd_results else -1,
472
+ ),
473
+ )
474
+
475
+ elif rule.action_type == ActionType.PROMPT:
476
+ # Collect for prompt output
477
+ prompt_results.append(result)
478
+
479
+ # Build response
480
+ messages: list[str] = []
481
+
482
+ # Add command errors if any
483
+ if command_errors:
484
+ messages.append("## Command Rule Errors\n")
485
+ messages.append("The following command rules failed.\n")
486
+ messages.extend(command_errors)
487
+ messages.append("")
488
+
489
+ # Add prompt rules if any
490
+ if prompt_results:
491
+ messages.append(format_rules_message(prompt_results))
492
+
493
+ if messages:
494
+ return HookOutput(decision="block", reason="\n".join(messages))
495
+
496
+ return HookOutput()
497
+
498
+
499
+ def main() -> None:
500
+ """Entry point for the rules check hook."""
501
+ # Determine platform from environment
502
+ platform_str = os.environ.get("DEEPWORK_HOOK_PLATFORM", "claude")
503
+ try:
504
+ platform = Platform(platform_str)
505
+ except ValueError:
506
+ platform = Platform.CLAUDE
507
+
508
+ # Run the hook with the wrapper
509
+ exit_code = run_hook(rules_check_hook, platform)
510
+ sys.exit(exit_code)
511
+
512
+
513
+ if __name__ == "__main__":
514
+ main()