deepwork 0.1.1__py3-none-any.whl → 0.3.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.
Files changed (59) hide show
  1. deepwork/cli/install.py +121 -32
  2. deepwork/cli/sync.py +20 -20
  3. deepwork/core/adapters.py +88 -51
  4. deepwork/core/command_executor.py +173 -0
  5. deepwork/core/generator.py +148 -31
  6. deepwork/core/hooks_syncer.py +51 -25
  7. deepwork/core/parser.py +8 -0
  8. deepwork/core/pattern_matcher.py +271 -0
  9. deepwork/core/rules_parser.py +511 -0
  10. deepwork/core/rules_queue.py +321 -0
  11. deepwork/hooks/README.md +181 -0
  12. deepwork/hooks/__init__.py +77 -1
  13. deepwork/hooks/claude_hook.sh +55 -0
  14. deepwork/hooks/gemini_hook.sh +55 -0
  15. deepwork/hooks/rules_check.py +514 -0
  16. deepwork/hooks/wrapper.py +363 -0
  17. deepwork/schemas/job_schema.py +14 -1
  18. deepwork/schemas/rules_schema.py +103 -0
  19. deepwork/standard_jobs/deepwork_jobs/AGENTS.md +60 -0
  20. deepwork/standard_jobs/deepwork_jobs/job.yml +41 -56
  21. deepwork/standard_jobs/deepwork_jobs/make_new_job.sh +134 -0
  22. deepwork/standard_jobs/deepwork_jobs/steps/define.md +29 -63
  23. deepwork/standard_jobs/deepwork_jobs/steps/implement.md +62 -263
  24. deepwork/standard_jobs/deepwork_jobs/steps/learn.md +4 -62
  25. deepwork/standard_jobs/deepwork_jobs/templates/agents.md.template +32 -0
  26. deepwork/standard_jobs/deepwork_jobs/templates/job.yml.example +73 -0
  27. deepwork/standard_jobs/deepwork_jobs/templates/job.yml.template +56 -0
  28. deepwork/standard_jobs/deepwork_jobs/templates/step_instruction.md.example +82 -0
  29. deepwork/standard_jobs/deepwork_jobs/templates/step_instruction.md.template +58 -0
  30. deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml +8 -0
  31. deepwork/standard_jobs/deepwork_rules/job.yml +39 -0
  32. deepwork/standard_jobs/deepwork_rules/rules/.gitkeep +13 -0
  33. deepwork/standard_jobs/deepwork_rules/rules/api-documentation-sync.md.example +10 -0
  34. deepwork/standard_jobs/deepwork_rules/rules/readme-documentation.md.example +10 -0
  35. deepwork/standard_jobs/deepwork_rules/rules/security-review.md.example +11 -0
  36. deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md +45 -0
  37. deepwork/standard_jobs/deepwork_rules/rules/source-test-pairing.md.example +13 -0
  38. deepwork/standard_jobs/deepwork_rules/steps/define.md +249 -0
  39. deepwork/templates/claude/skill-job-meta.md.jinja +70 -0
  40. deepwork/templates/claude/skill-job-step.md.jinja +198 -0
  41. deepwork/templates/gemini/skill-job-meta.toml.jinja +76 -0
  42. deepwork/templates/gemini/skill-job-step.toml.jinja +147 -0
  43. {deepwork-0.1.1.dist-info → deepwork-0.3.0.dist-info}/METADATA +54 -24
  44. deepwork-0.3.0.dist-info/RECORD +62 -0
  45. deepwork/core/policy_parser.py +0 -295
  46. deepwork/hooks/evaluate_policies.py +0 -376
  47. deepwork/schemas/policy_schema.py +0 -78
  48. deepwork/standard_jobs/deepwork_policy/hooks/global_hooks.yml +0 -8
  49. deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh +0 -56
  50. deepwork/standard_jobs/deepwork_policy/job.yml +0 -35
  51. deepwork/standard_jobs/deepwork_policy/steps/define.md +0 -195
  52. deepwork/templates/claude/command-job-step.md.jinja +0 -210
  53. deepwork/templates/gemini/command-job-step.toml.jinja +0 -169
  54. deepwork-0.1.1.dist-info/RECORD +0 -41
  55. /deepwork/standard_jobs/{deepwork_policy → deepwork_rules}/hooks/capture_prompt_work_tree.sh +0 -0
  56. /deepwork/standard_jobs/{deepwork_policy → deepwork_rules}/hooks/user_prompt_submit.sh +0 -0
  57. {deepwork-0.1.1.dist-info → deepwork-0.3.0.dist-info}/WHEEL +0 -0
  58. {deepwork-0.1.1.dist-info → deepwork-0.3.0.dist-info}/entry_points.txt +0 -0
  59. {deepwork-0.1.1.dist-info → deepwork-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
@@ -0,0 +1,173 @@
1
+ """Execute command actions for rules."""
2
+
3
+ import shlex
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from deepwork.core.rules_parser import CommandAction
9
+
10
+
11
+ @dataclass
12
+ class CommandResult:
13
+ """Result of executing a command."""
14
+
15
+ success: bool
16
+ exit_code: int
17
+ stdout: str
18
+ stderr: str
19
+ command: str # The actual command that was run
20
+
21
+
22
+ def substitute_command_variables(
23
+ command_template: str,
24
+ file: str | None = None,
25
+ files: list[str] | None = None,
26
+ repo_root: Path | None = None,
27
+ ) -> str:
28
+ """
29
+ Substitute template variables in a command string.
30
+
31
+ Variables:
32
+ - {file} - Single file path
33
+ - {files} - Space-separated file paths
34
+ - {repo_root} - Repository root directory
35
+
36
+ Args:
37
+ command_template: Command string with {var} placeholders
38
+ file: Single file path (for run_for: each_match)
39
+ files: List of file paths (for run_for: all_matches)
40
+ repo_root: Repository root path
41
+
42
+ Returns:
43
+ Command string with variables substituted
44
+ """
45
+ result = command_template
46
+
47
+ if file is not None:
48
+ # Quote file path to prevent command injection
49
+ result = result.replace("{file}", shlex.quote(file))
50
+
51
+ if files is not None:
52
+ # Quote each file path individually
53
+ quoted_files = " ".join(shlex.quote(f) for f in files)
54
+ result = result.replace("{files}", quoted_files)
55
+
56
+ if repo_root is not None:
57
+ result = result.replace("{repo_root}", shlex.quote(str(repo_root)))
58
+
59
+ return result
60
+
61
+
62
+ def execute_command(
63
+ command: str,
64
+ cwd: Path | None = None,
65
+ timeout: int = 60,
66
+ ) -> CommandResult:
67
+ """
68
+ Execute a command and capture output.
69
+
70
+ Args:
71
+ command: Command string to execute
72
+ cwd: Working directory (defaults to current directory)
73
+ timeout: Timeout in seconds
74
+
75
+ Returns:
76
+ CommandResult with execution details
77
+ """
78
+ try:
79
+ # Run command as shell to support pipes, etc.
80
+ result = subprocess.run(
81
+ command,
82
+ shell=True,
83
+ cwd=cwd,
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=timeout,
87
+ )
88
+
89
+ return CommandResult(
90
+ success=result.returncode == 0,
91
+ exit_code=result.returncode,
92
+ stdout=result.stdout,
93
+ stderr=result.stderr,
94
+ command=command,
95
+ )
96
+
97
+ except subprocess.TimeoutExpired:
98
+ return CommandResult(
99
+ success=False,
100
+ exit_code=-1,
101
+ stdout="",
102
+ stderr=f"Command timed out after {timeout} seconds",
103
+ command=command,
104
+ )
105
+ except Exception as e:
106
+ return CommandResult(
107
+ success=False,
108
+ exit_code=-1,
109
+ stdout="",
110
+ stderr=str(e),
111
+ command=command,
112
+ )
113
+
114
+
115
+ def run_command_action(
116
+ action: CommandAction,
117
+ trigger_files: list[str],
118
+ repo_root: Path | None = None,
119
+ ) -> list[CommandResult]:
120
+ """
121
+ Run a command action for the given trigger files.
122
+
123
+ Args:
124
+ action: CommandAction configuration
125
+ trigger_files: Files that triggered the rule
126
+ repo_root: Repository root path
127
+
128
+ Returns:
129
+ List of CommandResult (one per command execution)
130
+ """
131
+ results: list[CommandResult] = []
132
+
133
+ if action.run_for == "each_match":
134
+ # Run command for each file individually
135
+ for file_path in trigger_files:
136
+ command = substitute_command_variables(
137
+ action.command,
138
+ file=file_path,
139
+ repo_root=repo_root,
140
+ )
141
+ result = execute_command(command, cwd=repo_root)
142
+ results.append(result)
143
+
144
+ elif action.run_for == "all_matches":
145
+ # Run command once with all files
146
+ command = substitute_command_variables(
147
+ action.command,
148
+ files=trigger_files,
149
+ repo_root=repo_root,
150
+ )
151
+ result = execute_command(command, cwd=repo_root)
152
+ results.append(result)
153
+
154
+ return results
155
+
156
+
157
+ def all_commands_succeeded(results: list[CommandResult]) -> bool:
158
+ """Check if all command executions succeeded."""
159
+ return all(r.success for r in results)
160
+
161
+
162
+ def format_command_errors(results: list[CommandResult]) -> str:
163
+ """Format error messages from failed commands."""
164
+ errors: list[str] = []
165
+ for result in results:
166
+ if not result.success:
167
+ msg = f"Command failed: {result.command}\n"
168
+ if result.stderr:
169
+ msg += f"Error: {result.stderr}\n"
170
+ if result.exit_code != 0:
171
+ msg += f"Exit code: {result.exit_code}\n"
172
+ errors.append(msg)
173
+ return "\n".join(errors)
@@ -1,24 +1,24 @@
1
- """Slash-command file generator using Jinja2 templates."""
1
+ """Skill file generator using Jinja2 templates."""
2
2
 
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
6
  from jinja2 import Environment, FileSystemLoader, TemplateNotFound
7
7
 
8
- from deepwork.core.adapters import AgentAdapter, CommandLifecycleHook
8
+ from deepwork.core.adapters import AgentAdapter, SkillLifecycleHook
9
9
  from deepwork.core.parser import JobDefinition, Step
10
10
  from deepwork.schemas.job_schema import LIFECYCLE_HOOK_EVENTS
11
11
  from deepwork.utils.fs import safe_read, safe_write
12
12
 
13
13
 
14
14
  class GeneratorError(Exception):
15
- """Exception raised for command generation errors."""
15
+ """Exception raised for skill generation errors."""
16
16
 
17
17
  pass
18
18
 
19
19
 
20
- class CommandGenerator:
21
- """Generates slash-command files from job definitions."""
20
+ class SkillGenerator:
21
+ """Generates skill files from job definitions."""
22
22
 
23
23
  def __init__(self, templates_dir: Path | str | None = None):
24
24
  """
@@ -163,7 +163,7 @@ class CommandGenerator:
163
163
  for event in LIFECYCLE_HOOK_EVENTS:
164
164
  if event in step.hooks:
165
165
  # Get platform-specific event name from adapter
166
- hook_enum = CommandLifecycleHook(event)
166
+ hook_enum = SkillLifecycleHook(event)
167
167
  platform_event_name = adapter.get_platform_hook_name(hook_enum)
168
168
  if platform_event_name:
169
169
  hook_contexts = [
@@ -175,7 +175,7 @@ class CommandGenerator:
175
175
 
176
176
  # Backward compatibility: stop_hooks is after_agent hooks
177
177
  stop_hooks = hooks.get(
178
- adapter.get_platform_hook_name(CommandLifecycleHook.AFTER_AGENT) or "Stop", []
178
+ adapter.get_platform_hook_name(SkillLifecycleHook.AFTER_AGENT) or "Stop", []
179
179
  )
180
180
 
181
181
  return {
@@ -199,9 +199,117 @@ class CommandGenerator:
199
199
  "is_standalone": is_standalone,
200
200
  "hooks": hooks, # New: all hooks by platform event name
201
201
  "stop_hooks": stop_hooks, # Backward compat: after_agent hooks only
202
+ "quality_criteria": step.quality_criteria, # Declarative criteria with framing
202
203
  }
203
204
 
204
- def generate_step_command(
205
+ def _build_meta_skill_context(
206
+ self, job: JobDefinition, adapter: AgentAdapter
207
+ ) -> dict[str, Any]:
208
+ """
209
+ Build template context for a job's meta-skill.
210
+
211
+ Args:
212
+ job: Job definition
213
+ adapter: Agent adapter for platform-specific configuration
214
+
215
+ Returns:
216
+ Template context dictionary
217
+ """
218
+ # Build step info for the meta-skill
219
+ steps_info = []
220
+ for step in job.steps:
221
+ skill_filename = adapter.get_step_skill_filename(job.name, step.id, step.exposed)
222
+ # Extract just the skill name (without path and extension)
223
+ # For Claude: job_name.step_id/SKILL.md -> job_name.step_id
224
+ # For Gemini: job_name/step_id.toml -> job_name:step_id
225
+ if adapter.name == "gemini":
226
+ # Gemini uses colon for namespacing: job_name:step_id
227
+ parts = skill_filename.replace(".toml", "").split("/")
228
+ skill_name = ":".join(parts)
229
+ else:
230
+ # Claude uses directory/SKILL.md format, extract directory name
231
+ # job_name.step_id/SKILL.md -> job_name.step_id
232
+ skill_name = skill_filename.replace("/SKILL.md", "")
233
+
234
+ steps_info.append(
235
+ {
236
+ "id": step.id,
237
+ "name": step.name,
238
+ "description": step.description,
239
+ "command_name": skill_name,
240
+ "dependencies": step.dependencies,
241
+ "exposed": step.exposed,
242
+ }
243
+ )
244
+
245
+ return {
246
+ "job_name": job.name,
247
+ "job_version": job.version,
248
+ "job_summary": job.summary,
249
+ "job_description": job.description,
250
+ "total_steps": len(job.steps),
251
+ "steps": steps_info,
252
+ }
253
+
254
+ def generate_meta_skill(
255
+ self,
256
+ job: JobDefinition,
257
+ adapter: AgentAdapter,
258
+ output_dir: Path | str,
259
+ ) -> Path:
260
+ """
261
+ Generate the meta-skill file for a job.
262
+
263
+ The meta-skill is the primary user interface for a job, routing
264
+ user intent to the appropriate step.
265
+
266
+ Args:
267
+ job: Job definition
268
+ adapter: Agent adapter for the target platform
269
+ output_dir: Directory to write skill file to
270
+
271
+ Returns:
272
+ Path to generated meta-skill file
273
+
274
+ Raises:
275
+ GeneratorError: If generation fails
276
+ """
277
+ output_dir = Path(output_dir)
278
+
279
+ # Create skills subdirectory if needed
280
+ skills_dir = output_dir / adapter.skills_dir
281
+ skills_dir.mkdir(parents=True, exist_ok=True)
282
+
283
+ # Build context
284
+ context = self._build_meta_skill_context(job, adapter)
285
+
286
+ # Load and render template
287
+ env = self._get_jinja_env(adapter)
288
+ try:
289
+ template = env.get_template(adapter.meta_skill_template)
290
+ except TemplateNotFound as e:
291
+ raise GeneratorError(f"Meta-skill template not found: {e}") from e
292
+
293
+ try:
294
+ rendered = template.render(**context)
295
+ except Exception as e:
296
+ raise GeneratorError(f"Meta-skill template rendering failed: {e}") from e
297
+
298
+ # Write meta-skill file
299
+ skill_filename = adapter.get_meta_skill_filename(job.name)
300
+ skill_path = skills_dir / skill_filename
301
+
302
+ # Ensure parent directories exist (for Gemini's job_name/index.toml structure)
303
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
304
+
305
+ try:
306
+ safe_write(skill_path, rendered)
307
+ except Exception as e:
308
+ raise GeneratorError(f"Failed to write meta-skill file: {e}") from e
309
+
310
+ return skill_path
311
+
312
+ def generate_step_skill(
205
313
  self,
206
314
  job: JobDefinition,
207
315
  step: Step,
@@ -209,25 +317,25 @@ class CommandGenerator:
209
317
  output_dir: Path | str,
210
318
  ) -> Path:
211
319
  """
212
- Generate slash-command file for a single step.
320
+ Generate skill file for a single step.
213
321
 
214
322
  Args:
215
323
  job: Job definition
216
- step: Step to generate command for
324
+ step: Step to generate skill for
217
325
  adapter: Agent adapter for the target platform
218
- output_dir: Directory to write command file to
326
+ output_dir: Directory to write skill file to
219
327
 
220
328
  Returns:
221
- Path to generated command file
329
+ Path to generated skill file
222
330
 
223
331
  Raises:
224
332
  GeneratorError: If generation fails
225
333
  """
226
334
  output_dir = Path(output_dir)
227
335
 
228
- # Create commands subdirectory if needed
229
- commands_dir = output_dir / adapter.commands_dir
230
- commands_dir.mkdir(parents=True, exist_ok=True)
336
+ # Create skills subdirectory if needed
337
+ skills_dir = output_dir / adapter.skills_dir
338
+ skills_dir.mkdir(parents=True, exist_ok=True)
231
339
 
232
340
  # Find step index
233
341
  try:
@@ -235,13 +343,14 @@ class CommandGenerator:
235
343
  except StopIteration as e:
236
344
  raise GeneratorError(f"Step '{step.id}' not found in job '{job.name}'") from e
237
345
 
238
- # Build context
346
+ # Build context (include exposed for template user-invocable setting)
239
347
  context = self._build_step_context(job, step, step_index, adapter)
348
+ context["exposed"] = step.exposed
240
349
 
241
350
  # Load and render template
242
351
  env = self._get_jinja_env(adapter)
243
352
  try:
244
- template = env.get_template(adapter.command_template)
353
+ template = env.get_template(adapter.skill_template)
245
354
  except TemplateNotFound as e:
246
355
  raise GeneratorError(f"Template not found: {e}") from e
247
356
 
@@ -250,41 +359,49 @@ class CommandGenerator:
250
359
  except Exception as e:
251
360
  raise GeneratorError(f"Template rendering failed: {e}") from e
252
361
 
253
- # Write command file
254
- command_filename = adapter.get_command_filename(job.name, step.id)
255
- command_path = commands_dir / command_filename
362
+ # Write skill file
363
+ skill_filename = adapter.get_step_skill_filename(job.name, step.id, step.exposed)
364
+ skill_path = skills_dir / skill_filename
365
+
366
+ # Ensure parent directories exist (for Gemini's job_name/step_id.toml structure)
367
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
256
368
 
257
369
  try:
258
- safe_write(command_path, rendered)
370
+ safe_write(skill_path, rendered)
259
371
  except Exception as e:
260
- raise GeneratorError(f"Failed to write command file: {e}") from e
372
+ raise GeneratorError(f"Failed to write skill file: {e}") from e
261
373
 
262
- return command_path
374
+ return skill_path
263
375
 
264
- def generate_all_commands(
376
+ def generate_all_skills(
265
377
  self,
266
378
  job: JobDefinition,
267
379
  adapter: AgentAdapter,
268
380
  output_dir: Path | str,
269
381
  ) -> list[Path]:
270
382
  """
271
- Generate slash-command files for all steps in a job.
383
+ Generate all skill files for a job: meta-skill and step skills.
272
384
 
273
385
  Args:
274
386
  job: Job definition
275
387
  adapter: Agent adapter for the target platform
276
- output_dir: Directory to write command files to
388
+ output_dir: Directory to write skill files to
277
389
 
278
390
  Returns:
279
- List of paths to generated command files
391
+ List of paths to generated skill files (meta-skill first, then steps)
280
392
 
281
393
  Raises:
282
394
  GeneratorError: If generation fails
283
395
  """
284
- command_paths = []
396
+ skill_paths = []
397
+
398
+ # Generate meta-skill first (job-level entry point)
399
+ meta_skill_path = self.generate_meta_skill(job, adapter, output_dir)
400
+ skill_paths.append(meta_skill_path)
285
401
 
402
+ # Generate step skills
286
403
  for step in job.steps:
287
- command_path = self.generate_step_command(job, step, adapter, output_dir)
288
- command_paths.append(command_path)
404
+ skill_path = self.generate_step_skill(job, step, adapter, output_dir)
405
+ skill_paths.append(skill_path)
289
406
 
290
- return command_paths
407
+ return skill_paths
@@ -19,27 +19,42 @@ class HooksSyncError(Exception):
19
19
  class HookEntry:
20
20
  """Represents a single hook entry for a lifecycle event."""
21
21
 
22
- script: str # Script filename
23
22
  job_name: str # Job that provides this hook
24
23
  job_dir: Path # Full path to job directory
24
+ script: str | None = None # Script filename (if script-based hook)
25
+ module: str | None = None # Python module (if module-based hook)
25
26
 
26
- def get_script_path(self, project_path: Path) -> str:
27
+ def get_command(self, project_path: Path) -> str:
27
28
  """
28
- Get the script path relative to project root.
29
+ Get the command to run this hook.
29
30
 
30
31
  Args:
31
32
  project_path: Path to project root
32
33
 
33
34
  Returns:
34
- Relative path to script from project root
35
+ Command string to execute
35
36
  """
36
- # Script path is: .deepwork/jobs/{job_name}/hooks/{script}
37
- script_path = self.job_dir / "hooks" / self.script
38
- try:
39
- return str(script_path.relative_to(project_path))
40
- except ValueError:
41
- # If not relative, return the full path
42
- return str(script_path)
37
+ if self.module:
38
+ # Python module - run directly with python -m
39
+ return f"python -m {self.module}"
40
+ elif self.script:
41
+ # Script path is: .deepwork/jobs/{job_name}/hooks/{script}
42
+ script_path = self.job_dir / "hooks" / self.script
43
+ try:
44
+ return str(script_path.relative_to(project_path))
45
+ except ValueError:
46
+ # If not relative, return the full path
47
+ return str(script_path)
48
+ else:
49
+ raise ValueError("HookEntry must have either script or module")
50
+
51
+
52
+ @dataclass
53
+ class HookSpec:
54
+ """Specification for a single hook (either script or module)."""
55
+
56
+ script: str | None = None
57
+ module: str | None = None
43
58
 
44
59
 
45
60
  @dataclass
@@ -48,7 +63,7 @@ class JobHooks:
48
63
 
49
64
  job_name: str
50
65
  job_dir: Path
51
- hooks: dict[str, list[str]] = field(default_factory=dict) # event -> [scripts]
66
+ hooks: dict[str, list[HookSpec]] = field(default_factory=dict) # event -> [HookSpec]
52
67
 
53
68
  @classmethod
54
69
  def from_job_dir(cls, job_dir: Path) -> "JobHooks | None":
@@ -74,13 +89,23 @@ class JobHooks:
74
89
  if not data or not isinstance(data, dict):
75
90
  return None
76
91
 
77
- # Parse hooks - each key is an event, value is list of scripts
78
- hooks: dict[str, list[str]] = {}
79
- for event, scripts in data.items():
80
- if isinstance(scripts, list):
81
- hooks[event] = [str(s) for s in scripts]
82
- elif isinstance(scripts, str):
83
- hooks[event] = [scripts]
92
+ # Parse hooks - each key is an event, value is list of scripts or module specs
93
+ hooks: dict[str, list[HookSpec]] = {}
94
+ for event, entries in data.items():
95
+ if not isinstance(entries, list):
96
+ entries = [entries]
97
+
98
+ hook_specs: list[HookSpec] = []
99
+ for entry in entries:
100
+ if isinstance(entry, str):
101
+ # Simple script filename
102
+ hook_specs.append(HookSpec(script=entry))
103
+ elif isinstance(entry, dict) and "module" in entry:
104
+ # Python module specification
105
+ hook_specs.append(HookSpec(module=entry["module"]))
106
+
107
+ if hook_specs:
108
+ hooks[event] = hook_specs
84
109
 
85
110
  if not hooks:
86
111
  return None
@@ -134,17 +159,18 @@ def merge_hooks_for_platform(
134
159
  merged: dict[str, list[dict[str, Any]]] = {}
135
160
 
136
161
  for job_hooks in job_hooks_list:
137
- for event, scripts in job_hooks.hooks.items():
162
+ for event, hook_specs in job_hooks.hooks.items():
138
163
  if event not in merged:
139
164
  merged[event] = []
140
165
 
141
- for script in scripts:
166
+ for spec in hook_specs:
142
167
  entry = HookEntry(
143
- script=script,
144
168
  job_name=job_hooks.job_name,
145
169
  job_dir=job_hooks.job_dir,
170
+ script=spec.script,
171
+ module=spec.module,
146
172
  )
147
- script_path = entry.get_script_path(project_path)
173
+ command = entry.get_command(project_path)
148
174
 
149
175
  # Create hook configuration for Claude Code format
150
176
  hook_config = {
@@ -152,13 +178,13 @@ def merge_hooks_for_platform(
152
178
  "hooks": [
153
179
  {
154
180
  "type": "command",
155
- "command": script_path,
181
+ "command": command,
156
182
  }
157
183
  ],
158
184
  }
159
185
 
160
186
  # Check if this hook is already present (avoid duplicates)
161
- if not _hook_already_present(merged[event], script_path):
187
+ if not _hook_already_present(merged[event], command):
162
188
  merged[event].append(hook_config)
163
189
 
164
190
  return merged
deepwork/core/parser.py CHANGED
@@ -108,6 +108,12 @@ class Step:
108
108
  # Event names: after_agent, before_tool, before_prompt
109
109
  hooks: dict[str, list[HookAction]] = field(default_factory=dict)
110
110
 
111
+ # If true, skill is user-invocable in menus. Default: false (hidden from menus).
112
+ exposed: bool = False
113
+
114
+ # Declarative quality criteria rendered with standard evaluation framing
115
+ quality_criteria: list[str] = field(default_factory=list)
116
+
111
117
  @property
112
118
  def stop_hooks(self) -> list[HookAction]:
113
119
  """
@@ -144,6 +150,8 @@ class Step:
144
150
  outputs=data["outputs"],
145
151
  dependencies=data.get("dependencies", []),
146
152
  hooks=hooks,
153
+ exposed=data.get("exposed", False),
154
+ quality_criteria=data.get("quality_criteria", []),
147
155
  )
148
156
 
149
157