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,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()
|