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,700 @@
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
+
204
+ Returns files that changed since the prompt was submitted, including:
205
+ - Committed changes (compared to captured HEAD ref)
206
+ - Staged changes (not yet committed)
207
+ - Untracked files
208
+
209
+ This is used by trigger/safety, set, and pair mode rules to detect
210
+ file modifications during the agent response.
211
+ """
212
+ baseline_ref_path = Path(".deepwork/.last_head_ref")
213
+ changed_files: set[str] = set()
214
+
215
+ try:
216
+ # Stage all changes first
217
+ subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
218
+
219
+ # If we have a captured HEAD ref, compare committed changes against it
220
+ if baseline_ref_path.exists():
221
+ baseline_ref = baseline_ref_path.read_text().strip()
222
+ if baseline_ref:
223
+ # Get files changed in commits since the baseline
224
+ result = subprocess.run(
225
+ ["git", "diff", "--name-only", baseline_ref, "HEAD"],
226
+ capture_output=True,
227
+ text=True,
228
+ check=False,
229
+ )
230
+ if result.returncode == 0 and result.stdout.strip():
231
+ committed_files = set(result.stdout.strip().split("\n"))
232
+ changed_files.update(f for f in committed_files if f)
233
+
234
+ # Also get currently staged changes (in case not everything is committed)
235
+ result = subprocess.run(
236
+ ["git", "diff", "--name-only", "--cached"],
237
+ capture_output=True,
238
+ text=True,
239
+ check=False,
240
+ )
241
+ if result.stdout.strip():
242
+ staged_files = set(result.stdout.strip().split("\n"))
243
+ changed_files.update(f for f in staged_files if f)
244
+
245
+ # Include untracked files
246
+ result = subprocess.run(
247
+ ["git", "ls-files", "--others", "--exclude-standard"],
248
+ capture_output=True,
249
+ text=True,
250
+ check=False,
251
+ )
252
+ if result.stdout.strip():
253
+ untracked_files = set(result.stdout.strip().split("\n"))
254
+ changed_files.update(f for f in untracked_files if f)
255
+
256
+ return sorted(changed_files)
257
+
258
+ except (subprocess.CalledProcessError, OSError):
259
+ return []
260
+
261
+
262
+ def get_changed_files_for_mode(mode: str) -> list[str]:
263
+ """Get changed files for a specific compare_to mode."""
264
+ if mode == "base":
265
+ return get_changed_files_base()
266
+ elif mode == "default_tip":
267
+ return get_changed_files_default_tip()
268
+ elif mode == "prompt":
269
+ return get_changed_files_prompt()
270
+ else:
271
+ return get_changed_files_base()
272
+
273
+
274
+ def get_created_files_base() -> list[str]:
275
+ """Get files created (added) relative to branch base."""
276
+ default_branch = get_default_branch()
277
+
278
+ try:
279
+ result = subprocess.run(
280
+ ["git", "merge-base", "HEAD", f"origin/{default_branch}"],
281
+ capture_output=True,
282
+ text=True,
283
+ check=True,
284
+ )
285
+ merge_base = result.stdout.strip()
286
+
287
+ subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
288
+
289
+ # Get only added files (not modified) using --diff-filter=A
290
+ result = subprocess.run(
291
+ ["git", "diff", "--name-only", "--diff-filter=A", merge_base, "HEAD"],
292
+ capture_output=True,
293
+ text=True,
294
+ check=True,
295
+ )
296
+ committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
297
+
298
+ # Staged new files that don't exist in merge_base
299
+ result = subprocess.run(
300
+ ["git", "diff", "--name-only", "--diff-filter=A", "--cached", merge_base],
301
+ capture_output=True,
302
+ text=True,
303
+ check=False,
304
+ )
305
+ staged_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
306
+
307
+ # Untracked files are by definition "created"
308
+ result = subprocess.run(
309
+ ["git", "ls-files", "--others", "--exclude-standard"],
310
+ capture_output=True,
311
+ text=True,
312
+ check=False,
313
+ )
314
+ untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
315
+
316
+ all_created = committed_added | staged_added | untracked_files
317
+ return sorted([f for f in all_created if f])
318
+
319
+ except subprocess.CalledProcessError:
320
+ return []
321
+
322
+
323
+ def get_created_files_default_tip() -> list[str]:
324
+ """Get files created compared to default branch tip."""
325
+ default_branch = get_default_branch()
326
+
327
+ try:
328
+ subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
329
+
330
+ # Get only added files using --diff-filter=A
331
+ result = subprocess.run(
332
+ ["git", "diff", "--name-only", "--diff-filter=A", f"origin/{default_branch}..HEAD"],
333
+ capture_output=True,
334
+ text=True,
335
+ check=True,
336
+ )
337
+ committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
338
+
339
+ result = subprocess.run(
340
+ [
341
+ "git",
342
+ "diff",
343
+ "--name-only",
344
+ "--diff-filter=A",
345
+ "--cached",
346
+ f"origin/{default_branch}",
347
+ ],
348
+ capture_output=True,
349
+ text=True,
350
+ check=False,
351
+ )
352
+ staged_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
353
+
354
+ # Untracked files are by definition "created"
355
+ result = subprocess.run(
356
+ ["git", "ls-files", "--others", "--exclude-standard"],
357
+ capture_output=True,
358
+ text=True,
359
+ check=False,
360
+ )
361
+ untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
362
+
363
+ all_created = committed_added | staged_added | untracked_files
364
+ return sorted([f for f in all_created if f])
365
+
366
+ except subprocess.CalledProcessError:
367
+ return []
368
+
369
+
370
+ def get_created_files_prompt() -> list[str]:
371
+ """Get files created since prompt was submitted."""
372
+ baseline_path = Path(".deepwork/.last_work_tree")
373
+
374
+ try:
375
+ subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
376
+
377
+ result = subprocess.run(
378
+ ["git", "diff", "--name-only", "--cached"],
379
+ capture_output=True,
380
+ text=True,
381
+ check=False,
382
+ )
383
+ current_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
384
+ current_files = {f for f in current_files if f}
385
+
386
+ # Untracked files
387
+ result = subprocess.run(
388
+ ["git", "ls-files", "--others", "--exclude-standard"],
389
+ capture_output=True,
390
+ text=True,
391
+ check=False,
392
+ )
393
+ untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
394
+ untracked_files = {f for f in untracked_files if f}
395
+
396
+ all_current = current_files | untracked_files
397
+
398
+ if baseline_path.exists():
399
+ baseline_files = set(baseline_path.read_text().strip().split("\n"))
400
+ baseline_files = {f for f in baseline_files if f}
401
+ # Created files are those that didn't exist at baseline
402
+ created_files = all_current - baseline_files
403
+ return sorted(created_files)
404
+ else:
405
+ # No baseline means all current files are "new" to this prompt
406
+ return sorted(all_current)
407
+
408
+ except (subprocess.CalledProcessError, OSError):
409
+ return []
410
+
411
+
412
+ def get_created_files_for_mode(mode: str) -> list[str]:
413
+ """Get created files for a specific compare_to mode."""
414
+ if mode == "base":
415
+ return get_created_files_base()
416
+ elif mode == "default_tip":
417
+ return get_created_files_default_tip()
418
+ elif mode == "prompt":
419
+ return get_created_files_prompt()
420
+ else:
421
+ return get_created_files_base()
422
+
423
+
424
+ def extract_promise_tags(text: str) -> set[str]:
425
+ """
426
+ Extract rule names from <promise> tags in text.
427
+
428
+ Supports both:
429
+ - <promise>Rule Name</promise>
430
+ - <promise>✓ Rule Name</promise>
431
+ """
432
+ # Match with optional checkmark prefix (✓ or ✓ with space)
433
+ pattern = r"<promise>(?:\s*)?(?:✓\s*)?([^<]+)</promise>"
434
+ matches = re.findall(pattern, text, re.IGNORECASE | re.DOTALL)
435
+ return {m.strip() for m in matches}
436
+
437
+
438
+ def extract_conversation_from_transcript(transcript_path: str, platform: Platform) -> str:
439
+ """
440
+ Extract conversation text from a transcript file.
441
+
442
+ Handles platform-specific transcript formats.
443
+ """
444
+ if not transcript_path or not Path(transcript_path).exists():
445
+ return ""
446
+
447
+ try:
448
+ content = Path(transcript_path).read_text()
449
+
450
+ if platform == Platform.CLAUDE:
451
+ # Claude uses JSONL format - each line is a JSON object
452
+ conversation_parts = []
453
+ for line in content.strip().split("\n"):
454
+ if not line.strip():
455
+ continue
456
+ try:
457
+ entry = json.loads(line)
458
+ if entry.get("role") == "assistant":
459
+ message_content = entry.get("message", {}).get("content", [])
460
+ for part in message_content:
461
+ if part.get("type") == "text":
462
+ conversation_parts.append(part.get("text", ""))
463
+ except json.JSONDecodeError:
464
+ continue
465
+ return "\n".join(conversation_parts)
466
+
467
+ elif platform == Platform.GEMINI:
468
+ # Gemini uses JSON format
469
+ try:
470
+ data = json.loads(content)
471
+ # Extract text from messages
472
+ conversation_parts = []
473
+ messages = data.get("messages", [])
474
+ for msg in messages:
475
+ if msg.get("role") == "model":
476
+ parts = msg.get("parts", [])
477
+ for part in parts:
478
+ if isinstance(part, dict) and "text" in part:
479
+ conversation_parts.append(part["text"])
480
+ elif isinstance(part, str):
481
+ conversation_parts.append(part)
482
+ return "\n".join(conversation_parts)
483
+ except json.JSONDecodeError:
484
+ return ""
485
+
486
+ return ""
487
+ except Exception:
488
+ return ""
489
+
490
+
491
+ def format_rules_message(results: list[RuleEvaluationResult]) -> str:
492
+ """
493
+ Format triggered rules into a concise message for the agent.
494
+
495
+ Groups rules by name and uses minimal formatting.
496
+ """
497
+ lines = ["## DeepWork Rules Triggered", ""]
498
+ lines.append(
499
+ "Comply with the following rules. "
500
+ "To mark a rule as addressed, include `<promise>Rule Name</promise>` "
501
+ "in your response."
502
+ )
503
+ lines.append("")
504
+
505
+ # Group results by rule name
506
+ by_name: dict[str, list[RuleEvaluationResult]] = {}
507
+ for result in results:
508
+ name = result.rule.name
509
+ if name not in by_name:
510
+ by_name[name] = []
511
+ by_name[name].append(result)
512
+
513
+ for name, rule_results in by_name.items():
514
+ rule = rule_results[0].rule
515
+ lines.append(f"## {name}")
516
+ lines.append("")
517
+
518
+ # For set/pair modes, show the correspondence violations concisely
519
+ if rule.detection_mode in (DetectionMode.SET, DetectionMode.PAIR):
520
+ for result in rule_results:
521
+ for trigger_file in result.trigger_files:
522
+ for missing_file in result.missing_files:
523
+ lines.append(f"{trigger_file} -> {missing_file}")
524
+ lines.append("")
525
+
526
+ # Show instructions
527
+ if rule.instructions:
528
+ lines.append(rule.instructions.strip())
529
+ lines.append("")
530
+
531
+ return "\n".join(lines)
532
+
533
+
534
+ def rules_check_hook(hook_input: HookInput) -> HookOutput:
535
+ """
536
+ Main hook logic for rules evaluation (v2).
537
+
538
+ This is called for after_agent events to check if rules need attention
539
+ before allowing the agent to complete.
540
+ """
541
+ # Only process after_agent events
542
+ if hook_input.event != NormalizedEvent.AFTER_AGENT:
543
+ return HookOutput()
544
+
545
+ # Check if rules directory exists
546
+ rules_dir = Path(".deepwork/rules")
547
+ if not rules_dir.exists():
548
+ return HookOutput()
549
+
550
+ # Extract conversation context from transcript
551
+ conversation_context = extract_conversation_from_transcript(
552
+ hook_input.transcript_path, hook_input.platform
553
+ )
554
+
555
+ # Extract promise tags (case-insensitive)
556
+ promised_rules = extract_promise_tags(conversation_context)
557
+
558
+ # Load rules
559
+ try:
560
+ rules = load_rules_from_directory(rules_dir)
561
+ except RulesParseError as e:
562
+ print(f"Error loading rules: {e}", file=sys.stderr)
563
+ return HookOutput()
564
+
565
+ if not rules:
566
+ return HookOutput()
567
+
568
+ # Initialize queue
569
+ queue = RulesQueue()
570
+
571
+ # Group rules by compare_to mode
572
+ rules_by_mode: dict[str, list[Rule]] = {}
573
+ for rule in rules:
574
+ mode = rule.compare_to
575
+ if mode not in rules_by_mode:
576
+ rules_by_mode[mode] = []
577
+ rules_by_mode[mode].append(rule)
578
+
579
+ # Evaluate rules and collect results
580
+ prompt_results: list[RuleEvaluationResult] = []
581
+ command_errors: list[str] = []
582
+
583
+ for mode, mode_rules in rules_by_mode.items():
584
+ changed_files = get_changed_files_for_mode(mode)
585
+ created_files = get_created_files_for_mode(mode)
586
+
587
+ # Skip if no changed or created files
588
+ if not changed_files and not created_files:
589
+ continue
590
+
591
+ baseline_ref = get_baseline_ref(mode)
592
+
593
+ # Evaluate which rules fire
594
+ results = evaluate_rules(mode_rules, changed_files, promised_rules, created_files)
595
+
596
+ for result in results:
597
+ rule = result.rule
598
+
599
+ # Compute trigger hash for queue deduplication
600
+ trigger_hash = compute_trigger_hash(
601
+ rule.name,
602
+ result.trigger_files,
603
+ baseline_ref,
604
+ )
605
+
606
+ # Check if already in queue (passed/skipped)
607
+ existing = queue.get_entry(trigger_hash)
608
+ if existing and existing.status in (
609
+ QueueEntryStatus.PASSED,
610
+ QueueEntryStatus.SKIPPED,
611
+ ):
612
+ continue
613
+
614
+ # Create queue entry if new
615
+ if not existing:
616
+ queue.create_entry(
617
+ rule_name=rule.name,
618
+ rule_file=f"{rule.filename}.md",
619
+ trigger_files=result.trigger_files,
620
+ baseline_ref=baseline_ref,
621
+ expected_files=result.missing_files,
622
+ )
623
+
624
+ # Handle based on action type
625
+ if rule.action_type == ActionType.COMMAND:
626
+ # Run command action
627
+ if rule.command_action:
628
+ repo_root = Path.cwd()
629
+ cmd_results = run_command_action(
630
+ rule.command_action,
631
+ result.trigger_files,
632
+ repo_root,
633
+ )
634
+
635
+ if all_commands_succeeded(cmd_results):
636
+ # Command succeeded, mark as passed
637
+ queue.update_status(
638
+ trigger_hash,
639
+ QueueEntryStatus.PASSED,
640
+ ActionResult(
641
+ type="command",
642
+ output=cmd_results[0].stdout if cmd_results else None,
643
+ exit_code=0,
644
+ ),
645
+ )
646
+ else:
647
+ # Command failed
648
+ error_msg = format_command_errors(cmd_results)
649
+ skip_hint = f"To skip, include `<promise>✓ {rule.name}</promise>` in your response.\n"
650
+ command_errors.append(f"## {rule.name}\n{error_msg}{skip_hint}")
651
+ queue.update_status(
652
+ trigger_hash,
653
+ QueueEntryStatus.FAILED,
654
+ ActionResult(
655
+ type="command",
656
+ output=error_msg,
657
+ exit_code=cmd_results[0].exit_code if cmd_results else -1,
658
+ ),
659
+ )
660
+
661
+ elif rule.action_type == ActionType.PROMPT:
662
+ # Collect for prompt output
663
+ prompt_results.append(result)
664
+
665
+ # Build response
666
+ messages: list[str] = []
667
+
668
+ # Add command errors if any
669
+ if command_errors:
670
+ messages.append("## Command Rule Errors\n")
671
+ messages.append("The following command rules failed.\n")
672
+ messages.extend(command_errors)
673
+ messages.append("")
674
+
675
+ # Add prompt rules if any
676
+ if prompt_results:
677
+ messages.append(format_rules_message(prompt_results))
678
+
679
+ if messages:
680
+ return HookOutput(decision="block", reason="\n".join(messages))
681
+
682
+ return HookOutput()
683
+
684
+
685
+ def main() -> None:
686
+ """Entry point for the rules check hook."""
687
+ # Determine platform from environment
688
+ platform_str = os.environ.get("DEEPWORK_HOOK_PLATFORM", "claude")
689
+ try:
690
+ platform = Platform(platform_str)
691
+ except ValueError:
692
+ platform = Platform.CLAUDE
693
+
694
+ # Run the hook with the wrapper
695
+ exit_code = run_hook(rules_check_hook, platform)
696
+ sys.exit(exit_code)
697
+
698
+
699
+ if __name__ == "__main__":
700
+ main()