deepwork 0.3.1__py3-none-any.whl → 0.5.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.
Files changed (41) hide show
  1. deepwork/cli/hook.py +70 -0
  2. deepwork/cli/install.py +58 -14
  3. deepwork/cli/main.py +4 -0
  4. deepwork/cli/rules.py +32 -0
  5. deepwork/cli/sync.py +27 -1
  6. deepwork/core/adapters.py +213 -0
  7. deepwork/core/command_executor.py +26 -9
  8. deepwork/core/doc_spec_parser.py +205 -0
  9. deepwork/core/generator.py +79 -4
  10. deepwork/core/hooks_syncer.py +15 -2
  11. deepwork/core/parser.py +64 -2
  12. deepwork/hooks/__init__.py +9 -3
  13. deepwork/hooks/check_version.sh +230 -0
  14. deepwork/hooks/claude_hook.sh +13 -17
  15. deepwork/hooks/gemini_hook.sh +13 -17
  16. deepwork/hooks/rules_check.py +71 -12
  17. deepwork/hooks/wrapper.py +66 -16
  18. deepwork/schemas/doc_spec_schema.py +64 -0
  19. deepwork/schemas/job_schema.py +25 -3
  20. deepwork/standard_jobs/deepwork_jobs/doc_specs/job_spec.md +190 -0
  21. deepwork/standard_jobs/deepwork_jobs/job.yml +41 -8
  22. deepwork/standard_jobs/deepwork_jobs/steps/define.md +68 -2
  23. deepwork/standard_jobs/deepwork_jobs/steps/implement.md +3 -3
  24. deepwork/standard_jobs/deepwork_jobs/steps/learn.md +74 -5
  25. deepwork/standard_jobs/deepwork_jobs/steps/review_job_spec.md +208 -0
  26. deepwork/standard_jobs/deepwork_jobs/templates/doc_spec.md.example +86 -0
  27. deepwork/standard_jobs/deepwork_jobs/templates/doc_spec.md.template +26 -0
  28. deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +8 -0
  29. deepwork/standard_jobs/deepwork_rules/job.yml +5 -3
  30. deepwork/templates/claude/AGENTS.md +38 -0
  31. deepwork/templates/claude/skill-job-meta.md.jinja +7 -0
  32. deepwork/templates/claude/skill-job-step.md.jinja +107 -70
  33. deepwork/templates/gemini/skill-job-step.toml.jinja +18 -3
  34. deepwork/utils/fs.py +36 -0
  35. deepwork/utils/yaml_utils.py +24 -0
  36. {deepwork-0.3.1.dist-info → deepwork-0.5.1.dist-info}/METADATA +39 -2
  37. deepwork-0.5.1.dist-info/RECORD +72 -0
  38. deepwork-0.3.1.dist-info/RECORD +0 -62
  39. {deepwork-0.3.1.dist-info → deepwork-0.5.1.dist-info}/WHEEL +0 -0
  40. {deepwork-0.3.1.dist-info → deepwork-0.5.1.dist-info}/entry_points.txt +0 -0
  41. {deepwork-0.3.1.dist-info → deepwork-0.5.1.dist-info}/licenses/LICENSE.md +0 -0
deepwork/cli/hook.py ADDED
@@ -0,0 +1,70 @@
1
+ """Hook command for DeepWork CLI.
2
+
3
+ This command runs hook scripts, allowing hooks to use the `deepwork` CLI
4
+ instead of `python -m deepwork.hooks.*`, which works regardless of how
5
+ deepwork was installed (flake, pipx, uv, etc.).
6
+
7
+ Usage:
8
+ deepwork hook rules_check
9
+ deepwork hook <hook_name>
10
+
11
+ This is meant to be called from hook wrapper scripts (claude_hook.sh, gemini_hook.sh).
12
+ """
13
+
14
+ import importlib
15
+ import sys
16
+
17
+ import click
18
+ from rich.console import Console
19
+
20
+ console = Console()
21
+
22
+
23
+ class HookError(Exception):
24
+ """Exception raised for hook errors."""
25
+
26
+ pass
27
+
28
+
29
+ @click.command()
30
+ @click.argument("hook_name")
31
+ def hook(hook_name: str) -> None:
32
+ """
33
+ Run a DeepWork hook by name.
34
+
35
+ HOOK_NAME: Name of the hook to run (e.g., 'rules_check')
36
+
37
+ This command imports and runs the hook module from deepwork.hooks.{hook_name}.
38
+ The hook receives stdin input and outputs to stdout, following the hook protocol.
39
+
40
+ Examples:
41
+ deepwork hook rules_check
42
+ echo '{}' | deepwork hook rules_check
43
+ """
44
+ try:
45
+ # Import the hook module
46
+ # If the hook_name contains a dot, treat it as a full module path
47
+ # Otherwise, assume it's a hook in the deepwork.hooks package
48
+ if "." in hook_name:
49
+ module_name = hook_name
50
+ else:
51
+ module_name = f"deepwork.hooks.{hook_name}"
52
+ try:
53
+ module = importlib.import_module(module_name)
54
+ except ModuleNotFoundError:
55
+ raise HookError(
56
+ f"Hook '{hook_name}' not found. Available hooks are in the deepwork.hooks package."
57
+ ) from None
58
+
59
+ # Run the hook's main function if it exists
60
+ if hasattr(module, "main"):
61
+ sys.exit(module.main())
62
+ else:
63
+ raise HookError(f"Hook module '{module_name}' does not have a main() function")
64
+
65
+ except HookError as e:
66
+ console.print(f"[red]Error:[/red] {e}", style="bold red")
67
+ sys.exit(1)
68
+ except Exception as e:
69
+ console.print(f"[red]Unexpected error running hook:[/red] {e}", style="bold red")
70
+ sys.exit(1)
deepwork/cli/install.py CHANGED
@@ -8,7 +8,7 @@ from rich.console import Console
8
8
 
9
9
  from deepwork.core.adapters import AgentAdapter
10
10
  from deepwork.core.detector import PlatformDetector
11
- from deepwork.utils.fs import ensure_dir
11
+ from deepwork.utils.fs import ensure_dir, fix_permissions
12
12
  from deepwork.utils.git import is_git_repo
13
13
  from deepwork.utils.yaml_utils import load_yaml, save_yaml
14
14
 
@@ -52,9 +52,24 @@ def _inject_standard_job(job_name: str, jobs_dir: Path, project_path: Path) -> N
52
52
  shutil.rmtree(target_dir)
53
53
 
54
54
  shutil.copytree(standard_jobs_dir, target_dir)
55
+ # Fix permissions - source may have restrictive permissions (e.g., read-only)
56
+ fix_permissions(target_dir)
55
57
  console.print(
56
58
  f" [green]✓[/green] Installed {job_name} ({target_dir.relative_to(project_path)})"
57
59
  )
60
+
61
+ # Copy any doc specs from the standard job to .deepwork/doc_specs/
62
+ doc_specs_source = standard_jobs_dir / "doc_specs"
63
+ doc_specs_target = project_path / ".deepwork" / "doc_specs"
64
+ if doc_specs_source.exists():
65
+ for doc_spec_file in doc_specs_source.glob("*.md"):
66
+ target_doc_spec = doc_specs_target / doc_spec_file.name
67
+ shutil.copy(doc_spec_file, target_doc_spec)
68
+ # Fix permissions for copied doc spec
69
+ fix_permissions(target_doc_spec)
70
+ console.print(
71
+ f" [green]✓[/green] Installed doc spec {doc_spec_file.name} ({target_doc_spec.relative_to(project_path)})"
72
+ )
58
73
  except Exception as e:
59
74
  raise InstallError(f"Failed to install {job_name}: {e}") from e
60
75
 
@@ -91,26 +106,47 @@ def _create_deepwork_gitignore(deepwork_dir: Path) -> None:
91
106
  """
92
107
  Create .gitignore file in .deepwork/ directory.
93
108
 
94
- This ensures that temporary files like .last_work_tree are not committed.
109
+ This ensures that runtime artifacts are not committed while keeping
110
+ the tmp directory structure in version control.
95
111
 
96
112
  Args:
97
113
  deepwork_dir: Path to .deepwork directory
98
114
  """
99
115
  gitignore_path = deepwork_dir / ".gitignore"
100
- gitignore_content = """# DeepWork temporary files
101
- # These files are used for rules evaluation during sessions
116
+ gitignore_content = """# DeepWork runtime artifacts
117
+ # These files are generated during sessions and should not be committed
102
118
  .last_work_tree
119
+ .last_head_ref
120
+
121
+ # Temporary files (but keep the directory via .gitkeep)
122
+ tmp/*
123
+ !tmp/.gitkeep
103
124
  """
104
125
 
105
- # Only write if it doesn't exist or doesn't contain the entry
106
- if gitignore_path.exists():
107
- existing_content = gitignore_path.read_text()
108
- if ".last_work_tree" not in existing_content:
109
- # Append to existing
110
- with open(gitignore_path, "a") as f:
111
- f.write("\n" + gitignore_content)
112
- else:
113
- gitignore_path.write_text(gitignore_content)
126
+ # Always overwrite to ensure correct content
127
+ gitignore_path.write_text(gitignore_content)
128
+
129
+
130
+ def _create_tmp_directory(deepwork_dir: Path) -> None:
131
+ """
132
+ Create the .deepwork/tmp directory with a .gitkeep file.
133
+
134
+ This ensures the tmp directory exists in version control, which is required
135
+ for file permissions to work correctly when Claude Code starts fresh.
136
+
137
+ Args:
138
+ deepwork_dir: Path to .deepwork directory
139
+ """
140
+ tmp_dir = deepwork_dir / "tmp"
141
+ ensure_dir(tmp_dir)
142
+
143
+ gitkeep_file = tmp_dir / ".gitkeep"
144
+ if not gitkeep_file.exists():
145
+ gitkeep_file.write_text(
146
+ "# This file ensures the .deepwork/tmp directory exists in version control.\n"
147
+ "# The tmp directory is used for temporary files during DeepWork operations.\n"
148
+ "# Do not delete this file.\n"
149
+ )
114
150
 
115
151
 
116
152
  def _create_rules_directory(project_path: Path) -> bool:
@@ -142,6 +178,8 @@ def _create_rules_directory(project_path: Path) -> bool:
142
178
  for example_file in example_rules_dir.glob("*.md.example"):
143
179
  dest_file = rules_dir / example_file.name
144
180
  shutil.copy(example_file, dest_file)
181
+ # Fix permissions for copied rule template
182
+ fix_permissions(dest_file)
145
183
 
146
184
  # Create a README file explaining the rules system
147
185
  readme_content = """# DeepWork Rules
@@ -307,8 +345,10 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
307
345
  console.print("[yellow]→[/yellow] Creating DeepWork directory structure...")
308
346
  deepwork_dir = project_path / ".deepwork"
309
347
  jobs_dir = deepwork_dir / "jobs"
348
+ doc_specs_dir = deepwork_dir / "doc_specs"
310
349
  ensure_dir(deepwork_dir)
311
350
  ensure_dir(jobs_dir)
351
+ ensure_dir(doc_specs_dir)
312
352
  console.print(f" [green]✓[/green] Created {deepwork_dir.relative_to(project_path)}/")
313
353
 
314
354
  # Step 3b: Inject standard jobs (core job definitions)
@@ -320,7 +360,11 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
320
360
  _create_deepwork_gitignore(deepwork_dir)
321
361
  console.print(" [green]✓[/green] Created .deepwork/.gitignore")
322
362
 
323
- # Step 3d: Create rules directory with v2 templates
363
+ # Step 3d: Create tmp directory with .gitkeep file for version control
364
+ _create_tmp_directory(deepwork_dir)
365
+ console.print(" [green]✓[/green] Created .deepwork/tmp/.gitkeep")
366
+
367
+ # Step 3e: Create rules directory with v2 templates
324
368
  if _create_rules_directory(project_path):
325
369
  console.print(" [green]✓[/green] Created .deepwork/rules/ with example templates")
326
370
  else:
deepwork/cli/main.py CHANGED
@@ -14,11 +14,15 @@ def cli() -> None:
14
14
 
15
15
 
16
16
  # Import commands
17
+ from deepwork.cli.hook import hook # noqa: E402
17
18
  from deepwork.cli.install import install # noqa: E402
19
+ from deepwork.cli.rules import rules # noqa: E402
18
20
  from deepwork.cli.sync import sync # noqa: E402
19
21
 
20
22
  cli.add_command(install)
21
23
  cli.add_command(sync)
24
+ cli.add_command(hook)
25
+ cli.add_command(rules)
22
26
 
23
27
 
24
28
  if __name__ == "__main__":
deepwork/cli/rules.py ADDED
@@ -0,0 +1,32 @@
1
+ """Rules command for DeepWork CLI."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ from deepwork.core.rules_queue import RulesQueue
7
+
8
+ console = Console()
9
+
10
+
11
+ @click.group()
12
+ def rules() -> None:
13
+ """Manage DeepWork rules and queue."""
14
+ pass
15
+
16
+
17
+ @rules.command(name="clear_queue")
18
+ def clear_queue() -> None:
19
+ """
20
+ Clear all entries from the rules queue.
21
+
22
+ Removes all JSON files from .deepwork/tmp/rules/queue/.
23
+ This is useful for resetting the queue between tests or after
24
+ manual verification of rule states.
25
+ """
26
+ queue = RulesQueue()
27
+ count = queue.clear()
28
+
29
+ if count == 0:
30
+ console.print("[yellow]Queue is already empty[/yellow]")
31
+ else:
32
+ console.print(f"[green]Cleared {count} queue entry/entries[/green]")
deepwork/cli/sync.py CHANGED
@@ -136,11 +136,15 @@ def sync_skills(project_path: Path) -> None:
136
136
  ensure_dir(skills_dir)
137
137
 
138
138
  # Generate skills for all jobs
139
+ all_skill_paths: list[Path] = []
139
140
  if jobs:
140
141
  console.print(" [dim]•[/dim] Generating skills...")
141
142
  for job in jobs:
142
143
  try:
143
- job_paths = generator.generate_all_skills(job, adapter, platform_dir)
144
+ job_paths = generator.generate_all_skills(
145
+ job, adapter, platform_dir, project_root=project_path
146
+ )
147
+ all_skill_paths.extend(job_paths)
144
148
  stats["skills"] += len(job_paths)
145
149
  console.print(f" [green]✓[/green] {job.name} ({len(job_paths)} skills)")
146
150
  except Exception as e:
@@ -157,6 +161,28 @@ def sync_skills(project_path: Path) -> None:
157
161
  except Exception as e:
158
162
  console.print(f" [red]✗[/red] Failed to sync hooks: {e}")
159
163
 
164
+ # Sync required permissions to platform settings
165
+ console.print(" [dim]•[/dim] Syncing permissions...")
166
+ try:
167
+ perms_count = adapter.sync_permissions(project_path)
168
+ if perms_count > 0:
169
+ console.print(f" [green]✓[/green] Added {perms_count} base permission(s)")
170
+ else:
171
+ console.print(" [dim]•[/dim] Base permissions already configured")
172
+ except Exception as e:
173
+ console.print(f" [red]✗[/red] Failed to sync permissions: {e}")
174
+
175
+ # Add skill permissions for generated skills (if adapter supports it)
176
+ if all_skill_paths and hasattr(adapter, "add_skill_permissions"):
177
+ try:
178
+ skill_perms_count = adapter.add_skill_permissions(project_path, all_skill_paths)
179
+ if skill_perms_count > 0:
180
+ console.print(
181
+ f" [green]✓[/green] Added {skill_perms_count} skill permission(s)"
182
+ )
183
+ except Exception as e:
184
+ console.print(f" [red]✗[/red] Failed to sync skill permissions: {e}")
185
+
160
186
  stats["platforms"] += 1
161
187
  synced_adapters.append(adapter)
162
188
 
deepwork/core/adapters.py CHANGED
@@ -241,6 +241,25 @@ class AgentAdapter(ABC):
241
241
  """
242
242
  pass
243
243
 
244
+ def sync_permissions(self, project_path: Path) -> int:
245
+ """
246
+ Sync required permissions to platform settings.
247
+
248
+ This method adds any permissions that DeepWork requires to function
249
+ properly (e.g., access to .deepwork/tmp/ directory).
250
+
251
+ Args:
252
+ project_path: Path to project root
253
+
254
+ Returns:
255
+ Number of permissions added
256
+
257
+ Raises:
258
+ AdapterError: If sync fails
259
+ """
260
+ # Default implementation does nothing - subclasses can override
261
+ return 0
262
+
244
263
 
245
264
  def _hook_already_present(hooks: list[dict[str, Any]], script_path: str) -> bool:
246
265
  """Check if a hook with the given script path is already in the list."""
@@ -344,6 +363,200 @@ class ClaudeAdapter(AgentAdapter):
344
363
  total = sum(len(hooks_list) for hooks_list in hooks.values())
345
364
  return total
346
365
 
366
+ def _load_settings(self, project_path: Path) -> dict[str, Any]:
367
+ """
368
+ Load settings.json from the project.
369
+
370
+ Args:
371
+ project_path: Path to project root
372
+
373
+ Returns:
374
+ Settings dictionary (empty dict if file doesn't exist)
375
+
376
+ Raises:
377
+ AdapterError: If file exists but cannot be read
378
+ """
379
+ settings_file = project_path / self.config_dir / "settings.json"
380
+ if settings_file.exists():
381
+ try:
382
+ with open(settings_file, encoding="utf-8") as f:
383
+ result: dict[str, Any] = json.load(f)
384
+ return result
385
+ except (json.JSONDecodeError, OSError) as e:
386
+ raise AdapterError(f"Failed to read settings.json: {e}") from e
387
+ return {}
388
+
389
+ def _save_settings(self, project_path: Path, settings: dict[str, Any]) -> None:
390
+ """
391
+ Save settings.json to the project.
392
+
393
+ Args:
394
+ project_path: Path to project root
395
+ settings: Settings dictionary to save
396
+
397
+ Raises:
398
+ AdapterError: If file cannot be written
399
+ """
400
+ settings_file = project_path / self.config_dir / "settings.json"
401
+ try:
402
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
403
+ with open(settings_file, "w", encoding="utf-8") as f:
404
+ json.dump(settings, f, indent=2)
405
+ except OSError as e:
406
+ raise AdapterError(f"Failed to write settings.json: {e}") from e
407
+
408
+ def add_permission(
409
+ self, project_path: Path, permission: str, settings: dict[str, Any] | None = None
410
+ ) -> bool:
411
+ """
412
+ Add a single permission to settings.json allow list.
413
+
414
+ Args:
415
+ project_path: Path to project root
416
+ permission: The permission string to add (e.g., "Read(./.deepwork/tmp/**)")
417
+ settings: Optional pre-loaded settings dict. If provided, modifies in-place
418
+ and does NOT save to disk (caller is responsible for saving).
419
+ If None, loads settings, adds permission, and saves.
420
+
421
+ Returns:
422
+ True if permission was added, False if already present
423
+
424
+ Raises:
425
+ AdapterError: If settings cannot be read/written
426
+ """
427
+ save_after = settings is None
428
+ if settings is None:
429
+ settings = self._load_settings(project_path)
430
+
431
+ # Ensure permissions structure exists
432
+ if "permissions" not in settings:
433
+ settings["permissions"] = {}
434
+ if "allow" not in settings["permissions"]:
435
+ settings["permissions"]["allow"] = []
436
+
437
+ # Add permission if not already present
438
+ allow_list = settings["permissions"]["allow"]
439
+ if permission not in allow_list:
440
+ allow_list.append(permission)
441
+ if save_after:
442
+ self._save_settings(project_path, settings)
443
+ return True
444
+ return False
445
+
446
+ def sync_permissions(self, project_path: Path) -> int:
447
+ """
448
+ Sync required permissions to Claude Code settings.json.
449
+
450
+ Adds permissions for:
451
+ - .deepwork/** - full access to deepwork directory
452
+ - All deepwork CLI commands (deepwork:*)
453
+
454
+ Args:
455
+ project_path: Path to project root
456
+
457
+ Returns:
458
+ Number of permissions added
459
+
460
+ Raises:
461
+ AdapterError: If sync fails
462
+ """
463
+ # Define required permissions for DeepWork functionality
464
+ # Uses ./ prefix for paths relative to project root (per Claude Code docs)
465
+ required_permissions = [
466
+ # Full access to .deepwork directory
467
+ "Read(./.deepwork/**)",
468
+ "Edit(./.deepwork/**)",
469
+ "Write(./.deepwork/**)",
470
+ # All deepwork CLI commands
471
+ "Bash(deepwork:*)",
472
+ # Job scripts that need to be executable
473
+ "Bash(./.deepwork/jobs/deepwork_jobs/make_new_job.sh:*)",
474
+ ]
475
+ # NOTE: When modifying required_permissions, update the test assertion in
476
+ # tests/unit/test_adapters.py::TestClaudeAdapter::test_sync_permissions_idempotent
477
+
478
+ # Load settings once, add all permissions, then save once
479
+ settings = self._load_settings(project_path)
480
+ added_count = 0
481
+
482
+ for permission in required_permissions:
483
+ if self.add_permission(project_path, permission, settings):
484
+ added_count += 1
485
+
486
+ # Save if any permissions were added
487
+ if added_count > 0:
488
+ self._save_settings(project_path, settings)
489
+
490
+ return added_count
491
+
492
+ def add_skill_permissions(self, project_path: Path, skill_paths: list[Path]) -> int:
493
+ """
494
+ Add Skill permissions for generated skills to settings.json.
495
+
496
+ This allows Claude to invoke the skills without permission prompts.
497
+ Uses the Skill(name) permission syntax.
498
+
499
+ Note: Skill permissions are an emerging Claude Code feature and
500
+ behavior may vary between versions.
501
+
502
+ Args:
503
+ project_path: Path to project root
504
+ skill_paths: List of paths to generated skill files
505
+
506
+ Returns:
507
+ Number of permissions added
508
+
509
+ Raises:
510
+ AdapterError: If sync fails
511
+ """
512
+ if not skill_paths:
513
+ return 0
514
+
515
+ # Load settings once
516
+ settings = self._load_settings(project_path)
517
+ added_count = 0
518
+
519
+ for skill_path in skill_paths:
520
+ # Extract skill name from path
521
+ # Path format: .claude/skills/job_name/SKILL.md -> job_name
522
+ # Path format: .claude/skills/job_name.step_id/SKILL.md -> job_name.step_id
523
+ skill_name = self._extract_skill_name(skill_path)
524
+ if skill_name:
525
+ permission = f"Skill({skill_name})"
526
+ if self.add_permission(project_path, permission, settings):
527
+ added_count += 1
528
+
529
+ # Save if any permissions were added
530
+ if added_count > 0:
531
+ self._save_settings(project_path, settings)
532
+
533
+ return added_count
534
+
535
+ def _extract_skill_name(self, skill_path: Path) -> str | None:
536
+ """
537
+ Extract skill name from a skill file path.
538
+
539
+ Args:
540
+ skill_path: Path to skill file (e.g., .claude/skills/job_name/SKILL.md)
541
+
542
+ Returns:
543
+ Skill name (e.g., "job_name") or None if cannot extract
544
+ """
545
+ # Handle both absolute and relative paths
546
+ parts = skill_path.parts
547
+
548
+ # Find 'skills' directory and get the next part
549
+ try:
550
+ skills_idx = parts.index("skills")
551
+ if skills_idx + 1 < len(parts):
552
+ # The skill name is the directory after 'skills'
553
+ # e.g., skills/job_name/SKILL.md -> job_name
554
+ return parts[skills_idx + 1]
555
+ except ValueError:
556
+ pass
557
+
558
+ return None
559
+
347
560
 
348
561
  class GeminiAdapter(AgentAdapter):
349
562
  """Adapter for Gemini CLI.
@@ -159,15 +159,32 @@ def all_commands_succeeded(results: list[CommandResult]) -> bool:
159
159
  return all(r.success for r in results)
160
160
 
161
161
 
162
- def format_command_errors(results: list[CommandResult]) -> str:
163
- """Format error messages from failed commands."""
162
+ def format_command_errors(
163
+ results: list[CommandResult],
164
+ rule_name: str | None = None,
165
+ ) -> str:
166
+ """Format detailed error messages from failed commands.
167
+
168
+ Args:
169
+ results: List of command execution results
170
+ rule_name: Optional rule name to include in error message
171
+
172
+ Returns:
173
+ Formatted error message with command, exit code, stdout, and stderr
174
+ """
164
175
  errors: list[str] = []
165
176
  for result in results:
166
177
  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)
178
+ parts: list[str] = []
179
+ if rule_name:
180
+ parts.append(f"Rule: {rule_name}")
181
+ parts.append(f"Command: {result.command}")
182
+ parts.append(f"Exit code: {result.exit_code}")
183
+ if result.stdout and result.stdout.strip():
184
+ parts.append(f"Stdout:\n{result.stdout.strip()}")
185
+ if result.stderr and result.stderr.strip():
186
+ parts.append(f"Stderr:\n{result.stderr.strip()}")
187
+ if not result.stdout.strip() and not result.stderr.strip():
188
+ parts.append("(no output)")
189
+ errors.append("\n".join(parts))
190
+ return "\n\n".join(errors)