deepwork 0.5.1__py3-none-any.whl → 0.7.0a1__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 (66) hide show
  1. deepwork/__init__.py +1 -1
  2. deepwork/cli/hook.py +3 -4
  3. deepwork/cli/install.py +70 -117
  4. deepwork/cli/main.py +2 -2
  5. deepwork/cli/serve.py +133 -0
  6. deepwork/cli/sync.py +93 -58
  7. deepwork/core/adapters.py +91 -102
  8. deepwork/core/generator.py +19 -386
  9. deepwork/core/hooks_syncer.py +1 -1
  10. deepwork/core/parser.py +270 -1
  11. deepwork/hooks/README.md +0 -44
  12. deepwork/hooks/__init__.py +3 -6
  13. deepwork/hooks/check_version.sh +54 -21
  14. deepwork/mcp/__init__.py +23 -0
  15. deepwork/mcp/quality_gate.py +347 -0
  16. deepwork/mcp/schemas.py +263 -0
  17. deepwork/mcp/server.py +253 -0
  18. deepwork/mcp/state.py +422 -0
  19. deepwork/mcp/tools.py +394 -0
  20. deepwork/schemas/job.schema.json +347 -0
  21. deepwork/schemas/job_schema.py +27 -239
  22. deepwork/standard_jobs/deepwork_jobs/doc_specs/job_spec.md +9 -15
  23. deepwork/standard_jobs/deepwork_jobs/job.yml +146 -46
  24. deepwork/standard_jobs/deepwork_jobs/steps/define.md +100 -33
  25. deepwork/standard_jobs/deepwork_jobs/steps/errata.md +154 -0
  26. deepwork/standard_jobs/deepwork_jobs/steps/fix_jobs.md +207 -0
  27. deepwork/standard_jobs/deepwork_jobs/steps/fix_settings.md +177 -0
  28. deepwork/standard_jobs/deepwork_jobs/steps/implement.md +22 -138
  29. deepwork/standard_jobs/deepwork_jobs/steps/iterate.md +221 -0
  30. deepwork/standard_jobs/deepwork_jobs/steps/learn.md +2 -26
  31. deepwork/standard_jobs/deepwork_jobs/steps/test.md +154 -0
  32. deepwork/standard_jobs/deepwork_jobs/templates/job.yml.template +2 -0
  33. deepwork/templates/claude/settings.json +16 -0
  34. deepwork/templates/claude/skill-deepwork.md.jinja +37 -0
  35. deepwork/templates/gemini/skill-deepwork.md.jinja +37 -0
  36. deepwork-0.7.0a1.dist-info/METADATA +317 -0
  37. deepwork-0.7.0a1.dist-info/RECORD +64 -0
  38. deepwork/cli/rules.py +0 -32
  39. deepwork/core/command_executor.py +0 -190
  40. deepwork/core/pattern_matcher.py +0 -271
  41. deepwork/core/rules_parser.py +0 -559
  42. deepwork/core/rules_queue.py +0 -321
  43. deepwork/hooks/rules_check.py +0 -759
  44. deepwork/schemas/rules_schema.py +0 -135
  45. deepwork/standard_jobs/deepwork_jobs/steps/review_job_spec.md +0 -208
  46. deepwork/standard_jobs/deepwork_jobs/templates/doc_spec.md.example +0 -86
  47. deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +0 -38
  48. deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml +0 -8
  49. deepwork/standard_jobs/deepwork_rules/hooks/user_prompt_submit.sh +0 -16
  50. deepwork/standard_jobs/deepwork_rules/job.yml +0 -49
  51. deepwork/standard_jobs/deepwork_rules/rules/.gitkeep +0 -13
  52. deepwork/standard_jobs/deepwork_rules/rules/api-documentation-sync.md.example +0 -10
  53. deepwork/standard_jobs/deepwork_rules/rules/readme-documentation.md.example +0 -10
  54. deepwork/standard_jobs/deepwork_rules/rules/security-review.md.example +0 -11
  55. deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md +0 -46
  56. deepwork/standard_jobs/deepwork_rules/rules/source-test-pairing.md.example +0 -13
  57. deepwork/standard_jobs/deepwork_rules/steps/define.md +0 -249
  58. deepwork/templates/claude/skill-job-meta.md.jinja +0 -77
  59. deepwork/templates/claude/skill-job-step.md.jinja +0 -235
  60. deepwork/templates/gemini/skill-job-meta.toml.jinja +0 -76
  61. deepwork/templates/gemini/skill-job-step.toml.jinja +0 -162
  62. deepwork-0.5.1.dist-info/METADATA +0 -381
  63. deepwork-0.5.1.dist-info/RECORD +0 -72
  64. {deepwork-0.5.1.dist-info → deepwork-0.7.0a1.dist-info}/WHEEL +0 -0
  65. {deepwork-0.5.1.dist-info → deepwork-0.7.0a1.dist-info}/entry_points.txt +0 -0
  66. {deepwork-0.5.1.dist-info → deepwork-0.7.0a1.dist-info}/licenses/LICENSE.md +0 -0
@@ -1,759 +0,0 @@
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 - recommended):
10
- claude_hook.sh rules_check
11
- gemini_hook.sh rules_check
12
-
13
- Or directly via deepwork CLI:
14
- deepwork hook rules_check
15
-
16
- Or with platform environment variable:
17
- DEEPWORK_HOOK_PLATFORM=claude deepwork hook rules_check
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import json
23
- import os
24
- import re
25
- import subprocess
26
- import sys
27
- from pathlib import Path
28
-
29
- from deepwork.core.command_executor import (
30
- all_commands_succeeded,
31
- format_command_errors,
32
- run_command_action,
33
- )
34
- from deepwork.core.rules_parser import (
35
- ActionType,
36
- DetectionMode,
37
- Rule,
38
- RuleEvaluationResult,
39
- RulesParseError,
40
- evaluate_rules,
41
- load_rules_from_directory,
42
- )
43
- from deepwork.core.rules_queue import (
44
- ActionResult,
45
- QueueEntryStatus,
46
- RulesQueue,
47
- compute_trigger_hash,
48
- )
49
- from deepwork.hooks.wrapper import (
50
- HookInput,
51
- HookOutput,
52
- NormalizedEvent,
53
- Platform,
54
- run_hook,
55
- )
56
-
57
-
58
- def get_default_branch() -> str:
59
- """Get the default branch name (main or master)."""
60
- try:
61
- result = subprocess.run(
62
- ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
63
- capture_output=True,
64
- text=True,
65
- check=True,
66
- )
67
- return result.stdout.strip().split("/")[-1]
68
- except subprocess.CalledProcessError:
69
- pass
70
-
71
- for branch in ["main", "master"]:
72
- try:
73
- subprocess.run(
74
- ["git", "rev-parse", "--verify", f"origin/{branch}"],
75
- capture_output=True,
76
- check=True,
77
- )
78
- return branch
79
- except subprocess.CalledProcessError:
80
- continue
81
-
82
- return "main"
83
-
84
-
85
- def get_baseline_ref(mode: str) -> str:
86
- """Get the baseline reference for a compare_to mode."""
87
- if mode == "base":
88
- try:
89
- default_branch = get_default_branch()
90
- result = subprocess.run(
91
- ["git", "merge-base", "HEAD", f"origin/{default_branch}"],
92
- capture_output=True,
93
- text=True,
94
- check=True,
95
- )
96
- return result.stdout.strip()
97
- except subprocess.CalledProcessError:
98
- return "base"
99
- elif mode == "default_tip":
100
- try:
101
- default_branch = get_default_branch()
102
- result = subprocess.run(
103
- ["git", "rev-parse", f"origin/{default_branch}"],
104
- capture_output=True,
105
- text=True,
106
- check=True,
107
- )
108
- return result.stdout.strip()
109
- except subprocess.CalledProcessError:
110
- return "default_tip"
111
- elif mode == "prompt":
112
- baseline_path = Path(".deepwork/.last_work_tree")
113
- if baseline_path.exists():
114
- # Use file modification time as reference
115
- return str(int(baseline_path.stat().st_mtime))
116
- return "prompt"
117
- return mode
118
-
119
-
120
- def get_changed_files_base() -> list[str]:
121
- """Get files changed relative to branch base."""
122
- default_branch = get_default_branch()
123
-
124
- try:
125
- result = subprocess.run(
126
- ["git", "merge-base", "HEAD", f"origin/{default_branch}"],
127
- capture_output=True,
128
- text=True,
129
- check=True,
130
- )
131
- merge_base = result.stdout.strip()
132
-
133
- subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
134
-
135
- result = subprocess.run(
136
- ["git", "diff", "--name-only", merge_base, "HEAD"],
137
- capture_output=True,
138
- text=True,
139
- check=True,
140
- )
141
- committed_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
142
-
143
- result = subprocess.run(
144
- ["git", "diff", "--name-only", "--cached"],
145
- capture_output=True,
146
- text=True,
147
- check=False,
148
- )
149
- staged_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
150
-
151
- result = subprocess.run(
152
- ["git", "ls-files", "--others", "--exclude-standard"],
153
- capture_output=True,
154
- text=True,
155
- check=False,
156
- )
157
- untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
158
-
159
- all_files = committed_files | staged_files | untracked_files
160
- return sorted([f for f in all_files if f])
161
-
162
- except subprocess.CalledProcessError:
163
- return []
164
-
165
-
166
- def get_changed_files_default_tip() -> list[str]:
167
- """Get files changed compared to default branch tip."""
168
- default_branch = get_default_branch()
169
-
170
- try:
171
- subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
172
-
173
- result = subprocess.run(
174
- ["git", "diff", "--name-only", f"origin/{default_branch}..HEAD"],
175
- capture_output=True,
176
- text=True,
177
- check=True,
178
- )
179
- committed_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
180
-
181
- result = subprocess.run(
182
- ["git", "diff", "--name-only", "--cached"],
183
- capture_output=True,
184
- text=True,
185
- check=False,
186
- )
187
- staged_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
188
-
189
- result = subprocess.run(
190
- ["git", "ls-files", "--others", "--exclude-standard"],
191
- capture_output=True,
192
- text=True,
193
- check=False,
194
- )
195
- untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
196
-
197
- all_files = committed_files | staged_files | untracked_files
198
- return sorted([f for f in all_files if f])
199
-
200
- except subprocess.CalledProcessError:
201
- return []
202
-
203
-
204
- def get_changed_files_prompt() -> list[str]:
205
- """Get files changed since prompt was submitted.
206
-
207
- Returns files that changed since the prompt was submitted, including:
208
- - Committed changes (compared to captured HEAD ref)
209
- - Staged changes (not yet committed)
210
- - Untracked files
211
-
212
- This is used by trigger/safety, set, and pair mode rules to detect
213
- file modifications during the agent response.
214
- """
215
- baseline_ref_path = Path(".deepwork/.last_head_ref")
216
- changed_files: set[str] = set()
217
-
218
- try:
219
- # Stage all changes first
220
- subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
221
-
222
- # If we have a captured HEAD ref, compare committed changes against it
223
- if baseline_ref_path.exists():
224
- baseline_ref = baseline_ref_path.read_text().strip()
225
- if baseline_ref:
226
- # Get files changed in commits since the baseline
227
- result = subprocess.run(
228
- ["git", "diff", "--name-only", baseline_ref, "HEAD"],
229
- capture_output=True,
230
- text=True,
231
- check=False,
232
- )
233
- if result.returncode == 0 and result.stdout.strip():
234
- committed_files = set(result.stdout.strip().split("\n"))
235
- changed_files.update(f for f in committed_files if f)
236
-
237
- # Also get currently staged changes (in case not everything is committed)
238
- result = subprocess.run(
239
- ["git", "diff", "--name-only", "--cached"],
240
- capture_output=True,
241
- text=True,
242
- check=False,
243
- )
244
- if result.stdout.strip():
245
- staged_files = set(result.stdout.strip().split("\n"))
246
- changed_files.update(f for f in staged_files if f)
247
-
248
- # Include untracked files
249
- result = subprocess.run(
250
- ["git", "ls-files", "--others", "--exclude-standard"],
251
- capture_output=True,
252
- text=True,
253
- check=False,
254
- )
255
- if result.stdout.strip():
256
- untracked_files = set(result.stdout.strip().split("\n"))
257
- changed_files.update(f for f in untracked_files if f)
258
-
259
- return sorted(changed_files)
260
-
261
- except (subprocess.CalledProcessError, OSError):
262
- return []
263
-
264
-
265
- def get_changed_files_for_mode(mode: str) -> list[str]:
266
- """Get changed files for a specific compare_to mode."""
267
- if mode == "base":
268
- return get_changed_files_base()
269
- elif mode == "default_tip":
270
- return get_changed_files_default_tip()
271
- elif mode == "prompt":
272
- return get_changed_files_prompt()
273
- else:
274
- return get_changed_files_base()
275
-
276
-
277
- def get_created_files_base() -> list[str]:
278
- """Get files created (added) relative to branch base."""
279
- default_branch = get_default_branch()
280
-
281
- try:
282
- result = subprocess.run(
283
- ["git", "merge-base", "HEAD", f"origin/{default_branch}"],
284
- capture_output=True,
285
- text=True,
286
- check=True,
287
- )
288
- merge_base = result.stdout.strip()
289
-
290
- subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
291
-
292
- # Get only added files (not modified) using --diff-filter=A
293
- result = subprocess.run(
294
- ["git", "diff", "--name-only", "--diff-filter=A", merge_base, "HEAD"],
295
- capture_output=True,
296
- text=True,
297
- check=True,
298
- )
299
- committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
300
-
301
- # Staged new files that don't exist in merge_base
302
- result = subprocess.run(
303
- ["git", "diff", "--name-only", "--diff-filter=A", "--cached", merge_base],
304
- capture_output=True,
305
- text=True,
306
- check=False,
307
- )
308
- staged_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
309
-
310
- # Untracked files are by definition "created"
311
- result = subprocess.run(
312
- ["git", "ls-files", "--others", "--exclude-standard"],
313
- capture_output=True,
314
- text=True,
315
- check=False,
316
- )
317
- untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
318
-
319
- all_created = committed_added | staged_added | untracked_files
320
- return sorted([f for f in all_created if f])
321
-
322
- except subprocess.CalledProcessError:
323
- return []
324
-
325
-
326
- def get_created_files_default_tip() -> list[str]:
327
- """Get files created compared to default branch tip."""
328
- default_branch = get_default_branch()
329
-
330
- try:
331
- subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
332
-
333
- # Get only added files using --diff-filter=A
334
- result = subprocess.run(
335
- ["git", "diff", "--name-only", "--diff-filter=A", f"origin/{default_branch}..HEAD"],
336
- capture_output=True,
337
- text=True,
338
- check=True,
339
- )
340
- committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
341
-
342
- result = subprocess.run(
343
- [
344
- "git",
345
- "diff",
346
- "--name-only",
347
- "--diff-filter=A",
348
- "--cached",
349
- f"origin/{default_branch}",
350
- ],
351
- capture_output=True,
352
- text=True,
353
- check=False,
354
- )
355
- staged_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
356
-
357
- # Untracked files are by definition "created"
358
- result = subprocess.run(
359
- ["git", "ls-files", "--others", "--exclude-standard"],
360
- capture_output=True,
361
- text=True,
362
- check=False,
363
- )
364
- untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
365
-
366
- all_created = committed_added | staged_added | untracked_files
367
- return sorted([f for f in all_created if f])
368
-
369
- except subprocess.CalledProcessError:
370
- return []
371
-
372
-
373
- def get_created_files_prompt() -> list[str]:
374
- """Get files created since prompt was submitted."""
375
- baseline_path = Path(".deepwork/.last_work_tree")
376
-
377
- try:
378
- subprocess.run(["git", "add", "-A"], capture_output=True, check=False)
379
-
380
- result = subprocess.run(
381
- ["git", "diff", "--name-only", "--cached"],
382
- capture_output=True,
383
- text=True,
384
- check=False,
385
- )
386
- current_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
387
- current_files = {f for f in current_files if f}
388
-
389
- # Untracked files
390
- result = subprocess.run(
391
- ["git", "ls-files", "--others", "--exclude-standard"],
392
- capture_output=True,
393
- text=True,
394
- check=False,
395
- )
396
- untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
397
- untracked_files = {f for f in untracked_files if f}
398
-
399
- all_current = current_files | untracked_files
400
-
401
- if baseline_path.exists():
402
- baseline_files = set(baseline_path.read_text().strip().split("\n"))
403
- baseline_files = {f for f in baseline_files if f}
404
- # Created files are those that didn't exist at baseline
405
- created_files = all_current - baseline_files
406
- return sorted(created_files)
407
- else:
408
- # No baseline means all current files are "new" to this prompt
409
- return sorted(all_current)
410
-
411
- except (subprocess.CalledProcessError, OSError):
412
- return []
413
-
414
-
415
- def get_created_files_for_mode(mode: str) -> list[str]:
416
- """Get created files for a specific compare_to mode."""
417
- if mode == "base":
418
- return get_created_files_base()
419
- elif mode == "default_tip":
420
- return get_created_files_default_tip()
421
- elif mode == "prompt":
422
- return get_created_files_prompt()
423
- else:
424
- return get_created_files_base()
425
-
426
-
427
- def extract_promise_tags(text: str) -> set[str]:
428
- """
429
- Extract rule names from <promise> tags in text.
430
-
431
- Supports both:
432
- - <promise>Rule Name</promise>
433
- - <promise>✓ Rule Name</promise>
434
- """
435
- # Match with optional checkmark prefix (✓ or ✓ with space)
436
- pattern = r"<promise>(?:\s*)?(?:✓\s*)?([^<]+)</promise>"
437
- matches = re.findall(pattern, text, re.IGNORECASE | re.DOTALL)
438
- return {m.strip() for m in matches}
439
-
440
-
441
- def extract_conversation_from_transcript(transcript_path: str, platform: Platform) -> str:
442
- """
443
- Extract conversation text from a transcript file.
444
-
445
- Handles platform-specific transcript formats.
446
- """
447
- if not transcript_path or not Path(transcript_path).exists():
448
- return ""
449
-
450
- try:
451
- content = Path(transcript_path).read_text()
452
-
453
- if platform == Platform.CLAUDE:
454
- # Claude uses JSONL format - each line is a JSON object
455
- conversation_parts = []
456
- for line in content.strip().split("\n"):
457
- if not line.strip():
458
- continue
459
- try:
460
- entry = json.loads(line)
461
- if entry.get("role") == "assistant":
462
- message_content = entry.get("message", {}).get("content", [])
463
- for part in message_content:
464
- if part.get("type") == "text":
465
- conversation_parts.append(part.get("text", ""))
466
- except json.JSONDecodeError:
467
- continue
468
- return "\n".join(conversation_parts)
469
-
470
- elif platform == Platform.GEMINI:
471
- # Gemini uses JSON format
472
- try:
473
- data = json.loads(content)
474
- # Extract text from messages
475
- conversation_parts = []
476
- messages = data.get("messages", [])
477
- for msg in messages:
478
- if msg.get("role") == "model":
479
- parts = msg.get("parts", [])
480
- for part in parts:
481
- if isinstance(part, dict) and "text" in part:
482
- conversation_parts.append(part["text"])
483
- elif isinstance(part, str):
484
- conversation_parts.append(part)
485
- return "\n".join(conversation_parts)
486
- except json.JSONDecodeError:
487
- return ""
488
-
489
- return ""
490
- except Exception:
491
- return ""
492
-
493
-
494
- def format_rules_message(results: list[RuleEvaluationResult]) -> str:
495
- """
496
- Format triggered rules into a concise message for the agent.
497
-
498
- Groups rules by name and uses minimal formatting.
499
- """
500
- lines = ["## DeepWork Rules Triggered", ""]
501
- lines.append(
502
- "Comply with the following rules. "
503
- "To mark a rule as addressed, include `<promise>Rule Name</promise>` "
504
- "in your response."
505
- )
506
- lines.append("")
507
-
508
- # Group results by rule name
509
- by_name: dict[str, list[RuleEvaluationResult]] = {}
510
- for result in results:
511
- name = result.rule.name
512
- if name not in by_name:
513
- by_name[name] = []
514
- by_name[name].append(result)
515
-
516
- for name, rule_results in by_name.items():
517
- rule = rule_results[0].rule
518
- lines.append(f"## {name}")
519
- lines.append("")
520
-
521
- # For set/pair modes, show the correspondence violations concisely
522
- if rule.detection_mode in (DetectionMode.SET, DetectionMode.PAIR):
523
- for result in rule_results:
524
- for trigger_file in result.trigger_files:
525
- for missing_file in result.missing_files:
526
- lines.append(f"{trigger_file} -> {missing_file}")
527
- lines.append("")
528
-
529
- # Show instructions
530
- if rule.instructions:
531
- lines.append(rule.instructions.strip())
532
- lines.append("")
533
-
534
- return "\n".join(lines)
535
-
536
-
537
- def rules_check_hook(hook_input: HookInput) -> HookOutput:
538
- """
539
- Main hook logic for rules evaluation (v2).
540
-
541
- This is called for after_agent events to check if rules need attention
542
- before allowing the agent to complete.
543
- """
544
- # Only process after_agent events
545
- if hook_input.event != NormalizedEvent.AFTER_AGENT:
546
- return HookOutput()
547
-
548
- # Check if rules directory exists
549
- rules_dir = Path(".deepwork/rules")
550
- if not rules_dir.exists():
551
- return HookOutput()
552
-
553
- # Extract conversation context from transcript
554
- conversation_context = extract_conversation_from_transcript(
555
- hook_input.transcript_path, hook_input.platform
556
- )
557
-
558
- # Extract promise tags (case-insensitive)
559
- promised_rules = extract_promise_tags(conversation_context)
560
-
561
- # Load rules
562
- try:
563
- rules = load_rules_from_directory(rules_dir)
564
- except RulesParseError as e:
565
- print(f"Error loading rules: {e}", file=sys.stderr)
566
- return HookOutput()
567
-
568
- if not rules:
569
- return HookOutput()
570
-
571
- # Initialize queue
572
- queue = RulesQueue()
573
-
574
- # Group rules by compare_to mode
575
- rules_by_mode: dict[str, list[Rule]] = {}
576
- for rule in rules:
577
- mode = rule.compare_to
578
- if mode not in rules_by_mode:
579
- rules_by_mode[mode] = []
580
- rules_by_mode[mode].append(rule)
581
-
582
- # Evaluate rules and collect results
583
- prompt_results: list[RuleEvaluationResult] = []
584
- command_errors: list[str] = []
585
-
586
- for mode, mode_rules in rules_by_mode.items():
587
- changed_files = get_changed_files_for_mode(mode)
588
- created_files = get_created_files_for_mode(mode)
589
-
590
- # Skip if no changed or created files
591
- if not changed_files and not created_files:
592
- continue
593
-
594
- baseline_ref = get_baseline_ref(mode)
595
-
596
- # Evaluate which rules fire
597
- results = evaluate_rules(mode_rules, changed_files, promised_rules, created_files)
598
-
599
- for result in results:
600
- rule = result.rule
601
-
602
- # Compute trigger hash for queue deduplication
603
- trigger_hash = compute_trigger_hash(
604
- rule.name,
605
- result.trigger_files,
606
- baseline_ref,
607
- )
608
-
609
- # Check if already in queue (passed/skipped)
610
- existing = queue.get_entry(trigger_hash)
611
- if existing and existing.status in (
612
- QueueEntryStatus.PASSED,
613
- QueueEntryStatus.SKIPPED,
614
- ):
615
- continue
616
-
617
- # For PROMPT rules, also skip if already QUEUED (already shown to agent).
618
- # This prevents infinite loops when transcript is unavailable or promise
619
- # tags haven't been written yet. The agent has already seen this rule.
620
- if (
621
- existing
622
- and existing.status == QueueEntryStatus.QUEUED
623
- and rule.action_type == ActionType.PROMPT
624
- ):
625
- continue
626
-
627
- # For COMMAND rules with FAILED status, don't re-run the command.
628
- # The agent has already seen the error. If they provide a promise,
629
- # the after-loop logic will update the status to SKIPPED.
630
- if (
631
- existing
632
- and existing.status == QueueEntryStatus.FAILED
633
- and rule.action_type == ActionType.COMMAND
634
- ):
635
- continue
636
-
637
- # Create queue entry if new
638
- if not existing:
639
- queue.create_entry(
640
- rule_name=rule.name,
641
- rule_file=f"{rule.filename}.md",
642
- trigger_files=result.trigger_files,
643
- baseline_ref=baseline_ref,
644
- expected_files=result.missing_files,
645
- )
646
-
647
- # Handle based on action type
648
- if rule.action_type == ActionType.COMMAND:
649
- # Run command action
650
- if rule.command_action:
651
- repo_root = Path.cwd()
652
- cmd_results = run_command_action(
653
- rule.command_action,
654
- result.trigger_files,
655
- repo_root,
656
- )
657
-
658
- if all_commands_succeeded(cmd_results):
659
- # Command succeeded, mark as passed
660
- queue.update_status(
661
- trigger_hash,
662
- QueueEntryStatus.PASSED,
663
- ActionResult(
664
- type="command",
665
- output=cmd_results[0].stdout if cmd_results else None,
666
- exit_code=0,
667
- ),
668
- )
669
- else:
670
- # Command failed - format detailed error message
671
- error_msg = format_command_errors(cmd_results, rule_name=rule.name)
672
- skip_hint = f"\nTo skip, include `<promise>✓ {rule.name}</promise>` in your response."
673
- command_errors.append(f"{error_msg}{skip_hint}")
674
- queue.update_status(
675
- trigger_hash,
676
- QueueEntryStatus.FAILED,
677
- ActionResult(
678
- type="command",
679
- output=error_msg,
680
- exit_code=cmd_results[0].exit_code if cmd_results else -1,
681
- ),
682
- )
683
-
684
- elif rule.action_type == ActionType.PROMPT:
685
- # Collect for prompt output
686
- prompt_results.append(result)
687
-
688
- # Handle FAILED queue entries that have been promised
689
- # (These rules weren't in results because evaluate_rules skips promised rules,
690
- # but we need to update their queue status to SKIPPED)
691
- if promised_rules:
692
- promised_lower = {name.lower() for name in promised_rules}
693
- for entry in queue.get_all_entries():
694
- if (
695
- entry.status == QueueEntryStatus.FAILED
696
- and entry.rule_name.lower() in promised_lower
697
- ):
698
- queue.update_status(
699
- entry.trigger_hash,
700
- QueueEntryStatus.SKIPPED,
701
- ActionResult(
702
- type="command",
703
- output="Acknowledged via promise tag",
704
- exit_code=None,
705
- ),
706
- )
707
-
708
- # Build response
709
- messages: list[str] = []
710
-
711
- # Add command errors if any
712
- if command_errors:
713
- messages.append("## Command Rule Errors\n")
714
- messages.append("The following command rules failed.\n")
715
- messages.extend(command_errors)
716
- messages.append("")
717
-
718
- # Add prompt rules if any
719
- if prompt_results:
720
- messages.append(format_rules_message(prompt_results))
721
-
722
- if messages:
723
- return HookOutput(decision="block", reason="\n".join(messages))
724
-
725
- return HookOutput()
726
-
727
-
728
- def main() -> None:
729
- """Entry point for the rules check hook."""
730
- platform_str = os.environ.get("DEEPWORK_HOOK_PLATFORM", "claude")
731
- try:
732
- platform = Platform(platform_str)
733
- except ValueError:
734
- platform = Platform.CLAUDE
735
-
736
- exit_code = run_hook(rules_check_hook, platform)
737
- sys.exit(exit_code)
738
-
739
-
740
- if __name__ == "__main__":
741
- # Wrap entry point to catch early failures (e.g., import errors in wrapper.py)
742
- try:
743
- main()
744
- except Exception as e:
745
- # Last resort error handling - output JSON manually since wrapper may be broken
746
- import json
747
- import traceback
748
-
749
- error_output = {
750
- "decision": "block",
751
- "reason": (
752
- "## Hook Script Error\n\n"
753
- f"Error type: {type(e).__name__}\n"
754
- f"Error: {e}\n\n"
755
- f"Traceback:\n```\n{traceback.format_exc()}\n```"
756
- ),
757
- }
758
- print(json.dumps(error_output))
759
- sys.exit(0)