deepwork 0.4.0__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepwork/__init__.py +1 -1
- deepwork/cli/hook.py +3 -4
- deepwork/cli/install.py +70 -117
- deepwork/cli/main.py +2 -2
- deepwork/cli/serve.py +133 -0
- deepwork/cli/sync.py +93 -58
- deepwork/core/adapters.py +91 -98
- deepwork/core/generator.py +19 -386
- deepwork/core/hooks_syncer.py +1 -1
- deepwork/core/parser.py +270 -1
- deepwork/hooks/README.md +0 -44
- deepwork/hooks/__init__.py +3 -6
- deepwork/hooks/check_version.sh +54 -21
- deepwork/mcp/__init__.py +23 -0
- deepwork/mcp/quality_gate.py +347 -0
- deepwork/mcp/schemas.py +263 -0
- deepwork/mcp/server.py +253 -0
- deepwork/mcp/state.py +422 -0
- deepwork/mcp/tools.py +394 -0
- deepwork/schemas/job.schema.json +347 -0
- deepwork/schemas/job_schema.py +27 -239
- deepwork/standard_jobs/deepwork_jobs/doc_specs/job_spec.md +9 -15
- deepwork/standard_jobs/deepwork_jobs/job.yml +146 -46
- deepwork/standard_jobs/deepwork_jobs/steps/define.md +100 -33
- deepwork/standard_jobs/deepwork_jobs/steps/errata.md +154 -0
- deepwork/standard_jobs/deepwork_jobs/steps/fix_jobs.md +207 -0
- deepwork/standard_jobs/deepwork_jobs/steps/fix_settings.md +177 -0
- deepwork/standard_jobs/deepwork_jobs/steps/implement.md +22 -138
- deepwork/standard_jobs/deepwork_jobs/steps/iterate.md +221 -0
- deepwork/standard_jobs/deepwork_jobs/steps/learn.md +2 -26
- deepwork/standard_jobs/deepwork_jobs/steps/test.md +154 -0
- deepwork/standard_jobs/deepwork_jobs/templates/job.yml.template +2 -0
- deepwork/templates/claude/AGENTS.md +38 -0
- deepwork/templates/claude/settings.json +16 -0
- deepwork/templates/claude/skill-deepwork.md.jinja +37 -0
- deepwork/templates/gemini/skill-deepwork.md.jinja +37 -0
- deepwork-0.7.0.dist-info/METADATA +317 -0
- deepwork-0.7.0.dist-info/RECORD +64 -0
- deepwork/cli/rules.py +0 -32
- deepwork/core/command_executor.py +0 -190
- deepwork/core/pattern_matcher.py +0 -271
- deepwork/core/rules_parser.py +0 -559
- deepwork/core/rules_queue.py +0 -321
- deepwork/hooks/rules_check.py +0 -759
- deepwork/schemas/rules_schema.py +0 -135
- deepwork/standard_jobs/deepwork_jobs/steps/review_job_spec.md +0 -208
- deepwork/standard_jobs/deepwork_jobs/templates/doc_spec.md.example +0 -86
- deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +0 -38
- deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml +0 -8
- deepwork/standard_jobs/deepwork_rules/hooks/user_prompt_submit.sh +0 -16
- deepwork/standard_jobs/deepwork_rules/job.yml +0 -49
- deepwork/standard_jobs/deepwork_rules/rules/.gitkeep +0 -13
- deepwork/standard_jobs/deepwork_rules/rules/api-documentation-sync.md.example +0 -10
- deepwork/standard_jobs/deepwork_rules/rules/readme-documentation.md.example +0 -10
- deepwork/standard_jobs/deepwork_rules/rules/security-review.md.example +0 -11
- deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md +0 -46
- deepwork/standard_jobs/deepwork_rules/rules/source-test-pairing.md.example +0 -13
- deepwork/standard_jobs/deepwork_rules/steps/define.md +0 -249
- deepwork/templates/claude/skill-job-meta.md.jinja +0 -77
- deepwork/templates/claude/skill-job-step.md.jinja +0 -251
- deepwork/templates/gemini/skill-job-meta.toml.jinja +0 -76
- deepwork/templates/gemini/skill-job-step.toml.jinja +0 -162
- deepwork-0.4.0.dist-info/METADATA +0 -381
- deepwork-0.4.0.dist-info/RECORD +0 -71
- {deepwork-0.4.0.dist-info → deepwork-0.7.0.dist-info}/WHEEL +0 -0
- {deepwork-0.4.0.dist-info → deepwork-0.7.0.dist-info}/entry_points.txt +0 -0
- {deepwork-0.4.0.dist-info → deepwork-0.7.0.dist-info}/licenses/LICENSE.md +0 -0
deepwork/hooks/rules_check.py
DELETED
|
@@ -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)
|