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.
- deepwork/cli/install.py +116 -71
- deepwork/cli/sync.py +20 -20
- deepwork/core/adapters.py +88 -51
- deepwork/core/command_executor.py +173 -0
- deepwork/core/generator.py +148 -31
- deepwork/core/hooks_syncer.py +51 -25
- deepwork/core/parser.py +8 -0
- deepwork/core/pattern_matcher.py +271 -0
- deepwork/core/rules_parser.py +559 -0
- deepwork/core/rules_queue.py +321 -0
- deepwork/hooks/README.md +181 -0
- deepwork/hooks/__init__.py +77 -1
- deepwork/hooks/claude_hook.sh +55 -0
- deepwork/hooks/gemini_hook.sh +55 -0
- deepwork/hooks/rules_check.py +700 -0
- deepwork/hooks/wrapper.py +363 -0
- deepwork/schemas/job_schema.py +14 -1
- deepwork/schemas/rules_schema.py +135 -0
- deepwork/standard_jobs/deepwork_jobs/job.yml +35 -53
- deepwork/standard_jobs/deepwork_jobs/steps/define.md +9 -6
- deepwork/standard_jobs/deepwork_jobs/steps/implement.md +28 -26
- deepwork/standard_jobs/deepwork_jobs/steps/learn.md +2 -2
- deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +30 -0
- deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml +8 -0
- deepwork/standard_jobs/deepwork_rules/job.yml +47 -0
- deepwork/standard_jobs/deepwork_rules/rules/.gitkeep +13 -0
- deepwork/standard_jobs/deepwork_rules/rules/api-documentation-sync.md.example +10 -0
- deepwork/standard_jobs/deepwork_rules/rules/readme-documentation.md.example +10 -0
- deepwork/standard_jobs/deepwork_rules/rules/security-review.md.example +11 -0
- deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md +46 -0
- deepwork/standard_jobs/deepwork_rules/rules/source-test-pairing.md.example +13 -0
- deepwork/standard_jobs/deepwork_rules/steps/define.md +249 -0
- deepwork/templates/claude/skill-job-meta.md.jinja +70 -0
- deepwork/templates/claude/skill-job-step.md.jinja +198 -0
- deepwork/templates/gemini/skill-job-meta.toml.jinja +76 -0
- deepwork/templates/gemini/skill-job-step.toml.jinja +147 -0
- {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/METADATA +56 -25
- deepwork-0.3.1.dist-info/RECORD +62 -0
- deepwork/core/policy_parser.py +0 -295
- deepwork/hooks/evaluate_policies.py +0 -376
- deepwork/schemas/policy_schema.py +0 -78
- deepwork/standard_jobs/deepwork_policy/hooks/capture_prompt_work_tree.sh +0 -27
- deepwork/standard_jobs/deepwork_policy/hooks/global_hooks.yml +0 -8
- deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh +0 -56
- deepwork/standard_jobs/deepwork_policy/job.yml +0 -35
- deepwork/standard_jobs/deepwork_policy/steps/define.md +0 -195
- deepwork/templates/claude/command-job-step.md.jinja +0 -210
- deepwork/templates/default_policy.yml +0 -53
- deepwork/templates/gemini/command-job-step.toml.jinja +0 -169
- deepwork-0.2.0.dist-info/RECORD +0 -49
- /deepwork/standard_jobs/{deepwork_policy → deepwork_rules}/hooks/user_prompt_submit.sh +0 -0
- {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/WHEEL +0 -0
- {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/entry_points.txt +0 -0
- {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Queue system for tracking rule state in .deepwork/tmp/rules/queue/."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueueEntryStatus(Enum):
|
|
13
|
+
"""Status of a queue entry."""
|
|
14
|
+
|
|
15
|
+
QUEUED = "queued" # Detected, awaiting evaluation
|
|
16
|
+
PASSED = "passed" # Evaluated, rule satisfied (promise found or action succeeded)
|
|
17
|
+
FAILED = "failed" # Evaluated, rule not satisfied
|
|
18
|
+
SKIPPED = "skipped" # Safety pattern matched, skipped
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ActionResult:
|
|
23
|
+
"""Result of executing a rule action."""
|
|
24
|
+
|
|
25
|
+
type: str # "prompt" or "command"
|
|
26
|
+
output: str | None = None # Command stdout or prompt message shown
|
|
27
|
+
exit_code: int | None = None # Command exit code (None for prompt)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class QueueEntry:
|
|
32
|
+
"""A single entry in the rules queue."""
|
|
33
|
+
|
|
34
|
+
# Identity
|
|
35
|
+
rule_name: str # Human-friendly name
|
|
36
|
+
rule_file: str # Filename (e.g., "source-test-pairing.md")
|
|
37
|
+
trigger_hash: str # Hash for deduplication
|
|
38
|
+
|
|
39
|
+
# State
|
|
40
|
+
status: QueueEntryStatus = QueueEntryStatus.QUEUED
|
|
41
|
+
created_at: str = "" # ISO8601 timestamp
|
|
42
|
+
evaluated_at: str | None = None # ISO8601 timestamp
|
|
43
|
+
|
|
44
|
+
# Context
|
|
45
|
+
baseline_ref: str = "" # Commit hash or timestamp used as baseline
|
|
46
|
+
trigger_files: list[str] = field(default_factory=list)
|
|
47
|
+
expected_files: list[str] = field(default_factory=list) # For set/pair modes
|
|
48
|
+
matched_files: list[str] = field(default_factory=list) # Files that also changed
|
|
49
|
+
|
|
50
|
+
# Result
|
|
51
|
+
action_result: ActionResult | None = None
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
if not self.created_at:
|
|
55
|
+
self.created_at = datetime.now(UTC).isoformat()
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
"""Convert to dictionary for JSON serialization."""
|
|
59
|
+
data = asdict(self)
|
|
60
|
+
data["status"] = self.status.value
|
|
61
|
+
if self.action_result:
|
|
62
|
+
data["action_result"] = asdict(self.action_result)
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: dict[str, Any]) -> "QueueEntry":
|
|
67
|
+
"""Create from dictionary."""
|
|
68
|
+
action_result = None
|
|
69
|
+
if data.get("action_result"):
|
|
70
|
+
action_result = ActionResult(**data["action_result"])
|
|
71
|
+
|
|
72
|
+
return cls(
|
|
73
|
+
rule_name=data.get("rule_name", data.get("policy_name", "")),
|
|
74
|
+
rule_file=data.get("rule_file", data.get("policy_file", "")),
|
|
75
|
+
trigger_hash=data["trigger_hash"],
|
|
76
|
+
status=QueueEntryStatus(data["status"]),
|
|
77
|
+
created_at=data.get("created_at", ""),
|
|
78
|
+
evaluated_at=data.get("evaluated_at"),
|
|
79
|
+
baseline_ref=data.get("baseline_ref", ""),
|
|
80
|
+
trigger_files=data.get("trigger_files", []),
|
|
81
|
+
expected_files=data.get("expected_files", []),
|
|
82
|
+
matched_files=data.get("matched_files", []),
|
|
83
|
+
action_result=action_result,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compute_trigger_hash(
|
|
88
|
+
rule_name: str,
|
|
89
|
+
trigger_files: list[str],
|
|
90
|
+
baseline_ref: str,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Compute a hash for deduplication.
|
|
94
|
+
|
|
95
|
+
The hash is based on:
|
|
96
|
+
- Rule name
|
|
97
|
+
- Sorted list of trigger files
|
|
98
|
+
- Baseline reference (commit hash or timestamp)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
12-character hex hash
|
|
102
|
+
"""
|
|
103
|
+
hash_input = f"{rule_name}:{sorted(trigger_files)}:{baseline_ref}"
|
|
104
|
+
return hashlib.sha256(hash_input.encode()).hexdigest()[:12]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RulesQueue:
|
|
108
|
+
"""
|
|
109
|
+
Manages the rules queue in .deepwork/tmp/rules/queue/.
|
|
110
|
+
|
|
111
|
+
Queue entries are stored as JSON files named {hash}.{status}.json
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, queue_dir: Path | None = None):
|
|
115
|
+
"""
|
|
116
|
+
Initialize the queue.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
queue_dir: Path to queue directory. Defaults to .deepwork/tmp/rules/queue/
|
|
120
|
+
"""
|
|
121
|
+
if queue_dir is None:
|
|
122
|
+
queue_dir = Path(".deepwork/tmp/rules/queue")
|
|
123
|
+
self.queue_dir = queue_dir
|
|
124
|
+
|
|
125
|
+
def _ensure_dir(self) -> None:
|
|
126
|
+
"""Ensure queue directory exists."""
|
|
127
|
+
self.queue_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
|
|
129
|
+
def _get_entry_path(self, trigger_hash: str, status: QueueEntryStatus) -> Path:
|
|
130
|
+
"""Get path for an entry file."""
|
|
131
|
+
return self.queue_dir / f"{trigger_hash}.{status.value}.json"
|
|
132
|
+
|
|
133
|
+
def _find_entry_path(self, trigger_hash: str) -> Path | None:
|
|
134
|
+
"""Find existing entry file for a hash (any status)."""
|
|
135
|
+
for status in QueueEntryStatus:
|
|
136
|
+
path = self._get_entry_path(trigger_hash, status)
|
|
137
|
+
if path.exists():
|
|
138
|
+
return path
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def has_entry(self, trigger_hash: str) -> bool:
|
|
142
|
+
"""Check if an entry exists for this hash."""
|
|
143
|
+
return self._find_entry_path(trigger_hash) is not None
|
|
144
|
+
|
|
145
|
+
def get_entry(self, trigger_hash: str) -> QueueEntry | None:
|
|
146
|
+
"""Get an entry by hash."""
|
|
147
|
+
path = self._find_entry_path(trigger_hash)
|
|
148
|
+
if path is None:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
with open(path, encoding="utf-8") as f:
|
|
153
|
+
data = json.load(f)
|
|
154
|
+
return QueueEntry.from_dict(data)
|
|
155
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def create_entry(
|
|
159
|
+
self,
|
|
160
|
+
rule_name: str,
|
|
161
|
+
rule_file: str,
|
|
162
|
+
trigger_files: list[str],
|
|
163
|
+
baseline_ref: str,
|
|
164
|
+
expected_files: list[str] | None = None,
|
|
165
|
+
) -> QueueEntry | None:
|
|
166
|
+
"""
|
|
167
|
+
Create a new queue entry if one doesn't already exist.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
rule_name: Human-friendly rule name
|
|
171
|
+
rule_file: Rule filename (e.g., "source-test-pairing.md")
|
|
172
|
+
trigger_files: Files that triggered the rule
|
|
173
|
+
baseline_ref: Baseline reference for change detection
|
|
174
|
+
expected_files: Expected corresponding files (for set/pair)
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Created QueueEntry, or None if entry already exists
|
|
178
|
+
"""
|
|
179
|
+
trigger_hash = compute_trigger_hash(rule_name, trigger_files, baseline_ref)
|
|
180
|
+
|
|
181
|
+
# Check if already exists
|
|
182
|
+
if self.has_entry(trigger_hash):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
self._ensure_dir()
|
|
186
|
+
|
|
187
|
+
entry = QueueEntry(
|
|
188
|
+
rule_name=rule_name,
|
|
189
|
+
rule_file=rule_file,
|
|
190
|
+
trigger_hash=trigger_hash,
|
|
191
|
+
status=QueueEntryStatus.QUEUED,
|
|
192
|
+
baseline_ref=baseline_ref,
|
|
193
|
+
trigger_files=trigger_files,
|
|
194
|
+
expected_files=expected_files or [],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
path = self._get_entry_path(trigger_hash, QueueEntryStatus.QUEUED)
|
|
198
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
199
|
+
json.dump(entry.to_dict(), f, indent=2)
|
|
200
|
+
|
|
201
|
+
return entry
|
|
202
|
+
|
|
203
|
+
def update_status(
|
|
204
|
+
self,
|
|
205
|
+
trigger_hash: str,
|
|
206
|
+
new_status: QueueEntryStatus,
|
|
207
|
+
action_result: ActionResult | None = None,
|
|
208
|
+
) -> bool:
|
|
209
|
+
"""
|
|
210
|
+
Update the status of an entry.
|
|
211
|
+
|
|
212
|
+
This renames the file to reflect the new status.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
trigger_hash: Hash of the entry to update
|
|
216
|
+
new_status: New status
|
|
217
|
+
action_result: Optional result of action execution
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if updated, False if entry not found
|
|
221
|
+
"""
|
|
222
|
+
old_path = self._find_entry_path(trigger_hash)
|
|
223
|
+
if old_path is None:
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
# Load existing entry
|
|
227
|
+
try:
|
|
228
|
+
with open(old_path, encoding="utf-8") as f:
|
|
229
|
+
data = json.load(f)
|
|
230
|
+
except (json.JSONDecodeError, OSError):
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
# Update fields
|
|
234
|
+
data["status"] = new_status.value
|
|
235
|
+
data["evaluated_at"] = datetime.now(UTC).isoformat()
|
|
236
|
+
if action_result:
|
|
237
|
+
data["action_result"] = asdict(action_result)
|
|
238
|
+
|
|
239
|
+
# Write to new path
|
|
240
|
+
new_path = self._get_entry_path(trigger_hash, new_status)
|
|
241
|
+
|
|
242
|
+
# If status didn't change, just update in place
|
|
243
|
+
if old_path == new_path:
|
|
244
|
+
with open(new_path, "w", encoding="utf-8") as f:
|
|
245
|
+
json.dump(data, f, indent=2)
|
|
246
|
+
else:
|
|
247
|
+
# Write new file then delete old
|
|
248
|
+
with open(new_path, "w", encoding="utf-8") as f:
|
|
249
|
+
json.dump(data, f, indent=2)
|
|
250
|
+
old_path.unlink()
|
|
251
|
+
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
def get_queued_entries(self) -> list[QueueEntry]:
|
|
255
|
+
"""Get all entries with QUEUED status."""
|
|
256
|
+
if not self.queue_dir.exists():
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
entries = []
|
|
260
|
+
for path in self.queue_dir.glob("*.queued.json"):
|
|
261
|
+
try:
|
|
262
|
+
with open(path, encoding="utf-8") as f:
|
|
263
|
+
data = json.load(f)
|
|
264
|
+
entries.append(QueueEntry.from_dict(data))
|
|
265
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
return entries
|
|
269
|
+
|
|
270
|
+
def get_all_entries(self) -> list[QueueEntry]:
|
|
271
|
+
"""Get all entries regardless of status."""
|
|
272
|
+
if not self.queue_dir.exists():
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
entries = []
|
|
276
|
+
for path in self.queue_dir.glob("*.json"):
|
|
277
|
+
try:
|
|
278
|
+
with open(path, encoding="utf-8") as f:
|
|
279
|
+
data = json.load(f)
|
|
280
|
+
entries.append(QueueEntry.from_dict(data))
|
|
281
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
return entries
|
|
285
|
+
|
|
286
|
+
def clear(self) -> int:
|
|
287
|
+
"""
|
|
288
|
+
Clear all entries from the queue.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Number of entries removed
|
|
292
|
+
"""
|
|
293
|
+
if not self.queue_dir.exists():
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
count = 0
|
|
297
|
+
for path in self.queue_dir.glob("*.json"):
|
|
298
|
+
try:
|
|
299
|
+
path.unlink()
|
|
300
|
+
count += 1
|
|
301
|
+
except OSError:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
return count
|
|
305
|
+
|
|
306
|
+
def remove_entry(self, trigger_hash: str) -> bool:
|
|
307
|
+
"""
|
|
308
|
+
Remove an entry by hash.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
True if removed, False if not found
|
|
312
|
+
"""
|
|
313
|
+
path = self._find_entry_path(trigger_hash)
|
|
314
|
+
if path is None:
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
path.unlink()
|
|
319
|
+
return True
|
|
320
|
+
except OSError:
|
|
321
|
+
return False
|
deepwork/hooks/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# DeepWork Hooks
|
|
2
|
+
|
|
3
|
+
This directory contains the cross-platform hook system for DeepWork. Hooks allow validating and controlling AI agent behavior during execution.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The hook system provides:
|
|
8
|
+
|
|
9
|
+
1. **Platform-specific shell wrappers** that normalize input/output:
|
|
10
|
+
- `claude_hook.sh` - For Claude Code
|
|
11
|
+
- `gemini_hook.sh` - For Gemini CLI
|
|
12
|
+
|
|
13
|
+
2. **Common Python module** (`wrapper.py`) that handles:
|
|
14
|
+
- Input normalization (event names, tool names, JSON structure)
|
|
15
|
+
- Output denormalization (decision values, JSON structure)
|
|
16
|
+
- Cross-platform compatibility
|
|
17
|
+
|
|
18
|
+
3. **Hook implementations**:
|
|
19
|
+
- `rules_check.py` - Evaluates DeepWork rules on `after_agent` events
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Registering Hooks
|
|
24
|
+
|
|
25
|
+
#### Claude Code (`.claude/settings.json`)
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"hooks": {
|
|
30
|
+
"Stop": [
|
|
31
|
+
{
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "path/to/claude_hook.sh deepwork.hooks.rules_check"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
#### Gemini CLI (`.gemini/settings.json`)
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"hooks": {
|
|
49
|
+
"AfterAgent": [
|
|
50
|
+
{
|
|
51
|
+
"hooks": [
|
|
52
|
+
{
|
|
53
|
+
"type": "command",
|
|
54
|
+
"command": "path/to/gemini_hook.sh deepwork.hooks.rules_check"
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Writing Custom Hooks
|
|
64
|
+
|
|
65
|
+
1. Create a new Python module in `deepwork/hooks/`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
"""my_custom_hook.py - Example custom hook."""
|
|
69
|
+
|
|
70
|
+
import os
|
|
71
|
+
import sys
|
|
72
|
+
|
|
73
|
+
from deepwork.hooks.wrapper import (
|
|
74
|
+
HookInput,
|
|
75
|
+
HookOutput,
|
|
76
|
+
NormalizedEvent,
|
|
77
|
+
Platform,
|
|
78
|
+
run_hook,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def my_hook(hook_input: HookInput) -> HookOutput:
|
|
83
|
+
"""Hook logic that works on any platform."""
|
|
84
|
+
|
|
85
|
+
# Check the normalized event type
|
|
86
|
+
if hook_input.event == NormalizedEvent.AFTER_AGENT:
|
|
87
|
+
# Example: block if certain condition is met
|
|
88
|
+
if some_condition():
|
|
89
|
+
return HookOutput(
|
|
90
|
+
decision="block",
|
|
91
|
+
reason="Cannot complete until X is done"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
elif hook_input.event == NormalizedEvent.BEFORE_TOOL:
|
|
95
|
+
# Example: validate tool usage
|
|
96
|
+
if hook_input.tool_name == "write_file":
|
|
97
|
+
file_path = hook_input.tool_input.get("file_path", "")
|
|
98
|
+
if "/secrets/" in file_path:
|
|
99
|
+
return HookOutput(
|
|
100
|
+
decision="deny",
|
|
101
|
+
reason="Cannot write to secrets directory"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Allow the action
|
|
105
|
+
return HookOutput()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main() -> None:
|
|
109
|
+
"""Entry point called by shell wrappers."""
|
|
110
|
+
platform_str = os.environ.get("DEEPWORK_HOOK_PLATFORM", "claude")
|
|
111
|
+
try:
|
|
112
|
+
platform = Platform(platform_str)
|
|
113
|
+
except ValueError:
|
|
114
|
+
platform = Platform.CLAUDE
|
|
115
|
+
|
|
116
|
+
exit_code = run_hook(my_hook, platform)
|
|
117
|
+
sys.exit(exit_code)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
2. Register the hook using the appropriate shell wrapper.
|
|
125
|
+
|
|
126
|
+
## Event Mapping
|
|
127
|
+
|
|
128
|
+
| DeepWork Normalized | Claude Code | Gemini CLI |
|
|
129
|
+
|---------------------|-------------|------------|
|
|
130
|
+
| `after_agent` | Stop | AfterAgent |
|
|
131
|
+
| `before_tool` | PreToolUse | BeforeTool |
|
|
132
|
+
| `after_tool` | PostToolUse | AfterTool |
|
|
133
|
+
| `before_prompt` | UserPromptSubmit | BeforeAgent |
|
|
134
|
+
| `session_start` | SessionStart | SessionStart |
|
|
135
|
+
| `session_end` | SessionEnd | SessionEnd |
|
|
136
|
+
|
|
137
|
+
## Tool Name Mapping
|
|
138
|
+
|
|
139
|
+
| Normalized | Claude Code | Gemini CLI |
|
|
140
|
+
|------------|-------------|------------|
|
|
141
|
+
| `write_file` | Write | write_file |
|
|
142
|
+
| `read_file` | Read | read_file |
|
|
143
|
+
| `edit_file` | Edit | edit_file |
|
|
144
|
+
| `shell` | Bash | shell |
|
|
145
|
+
| `glob` | Glob | glob |
|
|
146
|
+
| `grep` | Grep | grep |
|
|
147
|
+
|
|
148
|
+
## Decision Values
|
|
149
|
+
|
|
150
|
+
| Effect | Claude Code | Gemini CLI |
|
|
151
|
+
|--------|-------------|------------|
|
|
152
|
+
| Block action | `"block"` | `"deny"` (auto-converted) |
|
|
153
|
+
| Allow action | `"allow"` or `{}` | `"allow"` or `{}` |
|
|
154
|
+
| Deny tool use | `"deny"` | `"deny"` |
|
|
155
|
+
|
|
156
|
+
The wrapper automatically converts `"block"` to `"deny"` for Gemini CLI.
|
|
157
|
+
|
|
158
|
+
## Exit Codes
|
|
159
|
+
|
|
160
|
+
| Code | Meaning |
|
|
161
|
+
|------|---------|
|
|
162
|
+
| 0 | Success (allow action) |
|
|
163
|
+
| 2 | Blocking error (prevent action) |
|
|
164
|
+
|
|
165
|
+
## Testing
|
|
166
|
+
|
|
167
|
+
Run the hook wrapper tests:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pytest tests/unit/test_hook_wrapper.py -v
|
|
171
|
+
pytest tests/shell_script_tests/test_hook_wrappers.py -v
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Files
|
|
175
|
+
|
|
176
|
+
| File | Purpose |
|
|
177
|
+
|------|---------|
|
|
178
|
+
| `wrapper.py` | Cross-platform input/output normalization |
|
|
179
|
+
| `claude_hook.sh` | Shell wrapper for Claude Code |
|
|
180
|
+
| `gemini_hook.sh` | Shell wrapper for Gemini CLI |
|
|
181
|
+
| `rules_check.py` | Cross-platform rule evaluation hook |
|
deepwork/hooks/__init__.py
CHANGED
|
@@ -1 +1,77 @@
|
|
|
1
|
-
"""DeepWork hooks package for
|
|
1
|
+
"""DeepWork hooks package for rules enforcement and lifecycle events.
|
|
2
|
+
|
|
3
|
+
This package provides:
|
|
4
|
+
|
|
5
|
+
1. Cross-platform hook wrapper system:
|
|
6
|
+
- wrapper.py: Normalizes input/output between Claude Code and Gemini CLI
|
|
7
|
+
- claude_hook.sh: Shell wrapper for Claude Code hooks
|
|
8
|
+
- gemini_hook.sh: Shell wrapper for Gemini CLI hooks
|
|
9
|
+
|
|
10
|
+
2. Hook implementations:
|
|
11
|
+
- rules_check.py: Evaluates rules on after_agent events
|
|
12
|
+
|
|
13
|
+
Usage with wrapper system:
|
|
14
|
+
# Register hook in .claude/settings.json:
|
|
15
|
+
{
|
|
16
|
+
"hooks": {
|
|
17
|
+
"Stop": [{
|
|
18
|
+
"hooks": [{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": ".deepwork/hooks/claude_hook.sh deepwork.hooks.rules_check"
|
|
21
|
+
}]
|
|
22
|
+
}]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Register hook in .gemini/settings.json:
|
|
27
|
+
{
|
|
28
|
+
"hooks": {
|
|
29
|
+
"AfterAgent": [{
|
|
30
|
+
"hooks": [{
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": ".gemini/hooks/gemini_hook.sh deepwork.hooks.rules_check"
|
|
33
|
+
}]
|
|
34
|
+
}]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Writing custom hooks:
|
|
39
|
+
from deepwork.hooks.wrapper import (
|
|
40
|
+
HookInput,
|
|
41
|
+
HookOutput,
|
|
42
|
+
NormalizedEvent,
|
|
43
|
+
Platform,
|
|
44
|
+
run_hook,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def my_hook(input: HookInput) -> HookOutput:
|
|
48
|
+
if input.event == NormalizedEvent.AFTER_AGENT:
|
|
49
|
+
if should_block():
|
|
50
|
+
return HookOutput(decision="block", reason="Complete X first")
|
|
51
|
+
return HookOutput()
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
import os, sys
|
|
55
|
+
platform = Platform(os.environ.get("DEEPWORK_HOOK_PLATFORM", "claude"))
|
|
56
|
+
sys.exit(run_hook(my_hook, platform))
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from deepwork.hooks.wrapper import (
|
|
60
|
+
HookInput,
|
|
61
|
+
HookOutput,
|
|
62
|
+
NormalizedEvent,
|
|
63
|
+
Platform,
|
|
64
|
+
denormalize_output,
|
|
65
|
+
normalize_input,
|
|
66
|
+
run_hook,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"HookInput",
|
|
71
|
+
"HookOutput",
|
|
72
|
+
"NormalizedEvent",
|
|
73
|
+
"Platform",
|
|
74
|
+
"normalize_input",
|
|
75
|
+
"denormalize_output",
|
|
76
|
+
"run_hook",
|
|
77
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# claude_hook.sh - Claude Code hook wrapper
|
|
3
|
+
#
|
|
4
|
+
# This script wraps Python hooks to work with Claude Code's hook system.
|
|
5
|
+
# It handles input/output normalization so Python hooks can be written once
|
|
6
|
+
# and work on any supported platform.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# claude_hook.sh <python_hook_module>
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# claude_hook.sh deepwork.hooks.rules_check
|
|
13
|
+
#
|
|
14
|
+
# The Python module should implement a main() function that:
|
|
15
|
+
# 1. Calls deepwork.hooks.wrapper.run_hook() with a hook function
|
|
16
|
+
# 2. The hook function receives HookInput and returns HookOutput
|
|
17
|
+
#
|
|
18
|
+
# Environment variables set by Claude Code:
|
|
19
|
+
# CLAUDE_PROJECT_DIR - Absolute path to project root
|
|
20
|
+
#
|
|
21
|
+
# Input (stdin): JSON from Claude Code hook system
|
|
22
|
+
# Output (stdout): JSON response for Claude Code
|
|
23
|
+
# Exit codes:
|
|
24
|
+
# 0 - Success (allow action)
|
|
25
|
+
# 2 - Blocking error (prevent action)
|
|
26
|
+
|
|
27
|
+
set -e
|
|
28
|
+
|
|
29
|
+
# Get the Python module to run
|
|
30
|
+
PYTHON_MODULE="${1:-}"
|
|
31
|
+
|
|
32
|
+
if [ -z "${PYTHON_MODULE}" ]; then
|
|
33
|
+
echo "Usage: claude_hook.sh <python_hook_module>" >&2
|
|
34
|
+
echo "Example: claude_hook.sh deepwork.hooks.rules_check" >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Read stdin into variable
|
|
39
|
+
HOOK_INPUT=""
|
|
40
|
+
if [ ! -t 0 ]; then
|
|
41
|
+
HOOK_INPUT=$(cat)
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Set platform environment variable for the Python module
|
|
45
|
+
export DEEPWORK_HOOK_PLATFORM="claude"
|
|
46
|
+
|
|
47
|
+
# Run the Python module, passing the input via stdin
|
|
48
|
+
# The Python module is responsible for:
|
|
49
|
+
# 1. Reading stdin (normalized by wrapper)
|
|
50
|
+
# 2. Processing the hook logic
|
|
51
|
+
# 3. Writing JSON to stdout
|
|
52
|
+
echo "${HOOK_INPUT}" | python -m "${PYTHON_MODULE}"
|
|
53
|
+
exit_code=$?
|
|
54
|
+
|
|
55
|
+
exit ${exit_code}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# gemini_hook.sh - Gemini CLI hook wrapper
|
|
3
|
+
#
|
|
4
|
+
# This script wraps Python hooks to work with Gemini CLI's hook system.
|
|
5
|
+
# It handles input/output normalization so Python hooks can be written once
|
|
6
|
+
# and work on any supported platform.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# gemini_hook.sh <python_hook_module>
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# gemini_hook.sh deepwork.hooks.rules_check
|
|
13
|
+
#
|
|
14
|
+
# The Python module should implement a main() function that:
|
|
15
|
+
# 1. Calls deepwork.hooks.wrapper.run_hook() with a hook function
|
|
16
|
+
# 2. The hook function receives HookInput and returns HookOutput
|
|
17
|
+
#
|
|
18
|
+
# Environment variables set by Gemini CLI:
|
|
19
|
+
# GEMINI_PROJECT_DIR - Absolute path to project root
|
|
20
|
+
#
|
|
21
|
+
# Input (stdin): JSON from Gemini CLI hook system
|
|
22
|
+
# Output (stdout): JSON response for Gemini CLI
|
|
23
|
+
# Exit codes:
|
|
24
|
+
# 0 - Success (allow action)
|
|
25
|
+
# 2 - Blocking error (prevent action)
|
|
26
|
+
|
|
27
|
+
set -e
|
|
28
|
+
|
|
29
|
+
# Get the Python module to run
|
|
30
|
+
PYTHON_MODULE="${1:-}"
|
|
31
|
+
|
|
32
|
+
if [ -z "${PYTHON_MODULE}" ]; then
|
|
33
|
+
echo "Usage: gemini_hook.sh <python_hook_module>" >&2
|
|
34
|
+
echo "Example: gemini_hook.sh deepwork.hooks.rules_check" >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Read stdin into variable
|
|
39
|
+
HOOK_INPUT=""
|
|
40
|
+
if [ ! -t 0 ]; then
|
|
41
|
+
HOOK_INPUT=$(cat)
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Set platform environment variable for the Python module
|
|
45
|
+
export DEEPWORK_HOOK_PLATFORM="gemini"
|
|
46
|
+
|
|
47
|
+
# Run the Python module, passing the input via stdin
|
|
48
|
+
# The Python module is responsible for:
|
|
49
|
+
# 1. Reading stdin (normalized by wrapper)
|
|
50
|
+
# 2. Processing the hook logic
|
|
51
|
+
# 3. Writing JSON to stdout
|
|
52
|
+
echo "${HOOK_INPUT}" | python -m "${PYTHON_MODULE}"
|
|
53
|
+
exit_code=$?
|
|
54
|
+
|
|
55
|
+
exit ${exit_code}
|