shotgun-sh 0.1.0.dev11__py3-none-any.whl → 0.1.0.dev13__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.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (49) hide show
  1. shotgun/agents/common.py +94 -79
  2. shotgun/agents/config/constants.py +18 -0
  3. shotgun/agents/config/manager.py +68 -16
  4. shotgun/agents/config/provider.py +11 -6
  5. shotgun/agents/models.py +6 -0
  6. shotgun/agents/plan.py +15 -37
  7. shotgun/agents/research.py +10 -45
  8. shotgun/agents/specify.py +97 -0
  9. shotgun/agents/tasks.py +7 -36
  10. shotgun/agents/tools/artifact_management.py +450 -0
  11. shotgun/agents/tools/file_management.py +2 -2
  12. shotgun/artifacts/__init__.py +17 -0
  13. shotgun/artifacts/exceptions.py +89 -0
  14. shotgun/artifacts/manager.py +529 -0
  15. shotgun/artifacts/models.py +332 -0
  16. shotgun/artifacts/service.py +463 -0
  17. shotgun/artifacts/templates/__init__.py +10 -0
  18. shotgun/artifacts/templates/loader.py +252 -0
  19. shotgun/artifacts/templates/models.py +136 -0
  20. shotgun/artifacts/templates/plan/delivery_and_release_plan.yaml +66 -0
  21. shotgun/artifacts/templates/research/market_research.yaml +585 -0
  22. shotgun/artifacts/templates/research/sdk_comparison.yaml +257 -0
  23. shotgun/artifacts/templates/specify/prd.yaml +331 -0
  24. shotgun/artifacts/templates/specify/product_spec.yaml +301 -0
  25. shotgun/artifacts/utils.py +76 -0
  26. shotgun/cli/plan.py +1 -4
  27. shotgun/cli/specify.py +69 -0
  28. shotgun/cli/tasks.py +0 -4
  29. shotgun/logging_config.py +23 -7
  30. shotgun/main.py +7 -6
  31. shotgun/prompts/agents/partials/artifact_system.j2 +32 -0
  32. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  33. shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
  34. shotgun/prompts/agents/partials/interactive_mode.j2 +10 -2
  35. shotgun/prompts/agents/plan.j2 +31 -32
  36. shotgun/prompts/agents/research.j2 +37 -29
  37. shotgun/prompts/agents/specify.j2 +31 -0
  38. shotgun/prompts/agents/tasks.j2 +27 -12
  39. shotgun/sdk/artifact_models.py +186 -0
  40. shotgun/sdk/artifacts.py +448 -0
  41. shotgun/tui/app.py +26 -7
  42. shotgun/tui/screens/chat.py +28 -3
  43. shotgun/tui/screens/directory_setup.py +113 -0
  44. {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/METADATA +2 -2
  45. {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/RECORD +48 -25
  46. shotgun/prompts/user/research.j2 +0 -5
  47. {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/WHEEL +0 -0
  48. {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/entry_points.txt +0 -0
  49. {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,529 @@
1
+ """File system manager for artifacts."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from shotgun.logging_config import setup_logger
10
+
11
+ from .exceptions import (
12
+ ArtifactFileSystemError,
13
+ ArtifactNotFoundError,
14
+ InvalidArtifactPathError,
15
+ SectionNotFoundError,
16
+ )
17
+ from .models import AgentMode, Artifact, ArtifactSection
18
+ from .utils import generate_artifact_name
19
+
20
+ logger = setup_logger(__name__)
21
+
22
+ TEMPLATE_FILENAME = ".template.yaml"
23
+
24
+
25
+ class ArtifactManager:
26
+ """Manages file system operations for artifacts within .shotgun directory."""
27
+
28
+ def __init__(self, base_path: Path | None = None) -> None:
29
+ """Initialize artifact manager.
30
+
31
+ Args:
32
+ base_path: Base path for artifacts. Defaults to .shotgun in current directory.
33
+ """
34
+ if base_path is None:
35
+ base_path = Path.cwd() / ".shotgun"
36
+ elif isinstance(base_path, str):
37
+ base_path = Path(base_path)
38
+
39
+ self.base_path = base_path
40
+ logger.debug("Initialized ArtifactManager with base_path: %s", base_path)
41
+
42
+ def _get_artifact_path(self, agent_mode: AgentMode, artifact_id: str) -> Path:
43
+ """Get the full path to an artifact directory."""
44
+ return self.base_path / agent_mode.value / artifact_id
45
+
46
+ def _validate_artifact_path(self, path: Path) -> None:
47
+ """Validate that artifact path is within allowed directories."""
48
+ try:
49
+ resolved_path = path.resolve()
50
+ base_resolved = self.base_path.resolve()
51
+ resolved_path.relative_to(base_resolved)
52
+ except ValueError as e:
53
+ raise InvalidArtifactPathError(
54
+ str(path), "Path is outside .shotgun directory"
55
+ ) from e
56
+
57
+ def _get_template_path(self, artifact_path: Path) -> Path:
58
+ """Get path to template file for an artifact."""
59
+ return artifact_path / TEMPLATE_FILENAME
60
+
61
+ def _save_template(
62
+ self, artifact_path: Path, template_content: dict[str, Any]
63
+ ) -> None:
64
+ """Save template content to artifact directory."""
65
+ template_path = self._get_template_path(artifact_path)
66
+ try:
67
+ with open(template_path, "w", encoding="utf-8") as f:
68
+ yaml.dump(template_content, f, indent=2, default_flow_style=False)
69
+ logger.debug("Saved template file: %s", template_path)
70
+ except Exception as e:
71
+ raise ArtifactFileSystemError(
72
+ "save template", str(template_path), str(e)
73
+ ) from e
74
+
75
+ def _load_template(self, artifact_path: Path) -> dict[str, Any] | None:
76
+ """Load template content from artifact directory."""
77
+ template_path = self._get_template_path(artifact_path)
78
+ if not template_path.exists():
79
+ return None
80
+
81
+ try:
82
+ with open(template_path, encoding="utf-8") as f:
83
+ data = yaml.safe_load(f)
84
+ return data if isinstance(data, dict) else None
85
+ except Exception as e:
86
+ logger.warning("Failed to load template from %s: %s", template_path, str(e))
87
+ return None
88
+
89
+ def artifact_exists(self, agent_mode: AgentMode, artifact_id: str) -> bool:
90
+ """Check if an artifact exists."""
91
+ artifact_path = self._get_artifact_path(agent_mode, artifact_id)
92
+ return artifact_path.exists() and artifact_path.is_dir()
93
+
94
+ def create_artifact(self, artifact: Artifact) -> None:
95
+ """Create a new artifact on the file system."""
96
+ artifact_path = self._get_artifact_path(
97
+ artifact.agent_mode, artifact.artifact_id
98
+ )
99
+ self._validate_artifact_path(artifact_path)
100
+
101
+ if artifact_path.exists():
102
+ raise ArtifactFileSystemError(
103
+ "create artifact",
104
+ str(artifact_path),
105
+ "Directory already exists",
106
+ )
107
+
108
+ try:
109
+ # Create artifact directory
110
+ artifact_path.mkdir(parents=True, exist_ok=False)
111
+ logger.debug("Created artifact directory: %s", artifact_path)
112
+
113
+ # Save sections
114
+ for section in artifact.sections:
115
+ self._write_section_file(artifact_path, section)
116
+
117
+ # Template saving is now handled by the service layer via save_template_file method
118
+
119
+ except Exception as e:
120
+ # Clean up on failure
121
+ if artifact_path.exists():
122
+ try:
123
+ self._remove_directory(artifact_path)
124
+ except Exception as cleanup_error:
125
+ logger.debug(
126
+ "Failed to cleanup directory %s: %s",
127
+ artifact_path,
128
+ cleanup_error,
129
+ )
130
+ raise ArtifactFileSystemError(
131
+ "create artifact", str(artifact_path), str(e)
132
+ ) from e
133
+
134
+ def load_artifact(
135
+ self, agent_mode: AgentMode, artifact_id: str, name: str | None = None
136
+ ) -> Artifact:
137
+ """Load an artifact from the file system."""
138
+ artifact_path = self._get_artifact_path(agent_mode, artifact_id)
139
+
140
+ if not artifact_path.exists():
141
+ raise ArtifactNotFoundError(artifact_id, agent_mode.value)
142
+
143
+ try:
144
+ # Load sections
145
+ sections: list[ArtifactSection] = []
146
+ section_files = list(artifact_path.glob("*.md"))
147
+
148
+ for section_file in section_files:
149
+ section = self._load_section_from_file(section_file)
150
+ if section:
151
+ sections.append(section)
152
+
153
+ # Sort sections by number
154
+ sections.sort(key=lambda s: s.number)
155
+
156
+ # Generate a default name if none provided
157
+ if not name:
158
+ name = generate_artifact_name(artifact_id)
159
+
160
+ artifact = Artifact(
161
+ artifact_id=artifact_id,
162
+ agent_mode=agent_mode,
163
+ name=name,
164
+ sections=sections,
165
+ )
166
+
167
+ return artifact
168
+
169
+ except ArtifactNotFoundError:
170
+ raise
171
+ except Exception as e:
172
+ raise ArtifactFileSystemError(
173
+ "load artifact", str(artifact_path), str(e)
174
+ ) from e
175
+
176
+ def save_artifact(self, artifact: Artifact) -> None:
177
+ """Save an existing artifact to the file system."""
178
+ artifact_path = self._get_artifact_path(
179
+ artifact.agent_mode, artifact.artifact_id
180
+ )
181
+
182
+ if not artifact_path.exists():
183
+ raise ArtifactNotFoundError(artifact.artifact_id, artifact.agent_mode.value)
184
+
185
+ try:
186
+ # Get existing section files
187
+ existing_files = {f.name for f in artifact_path.glob("*.md")}
188
+
189
+ # Write current sections
190
+ current_files = set()
191
+ for section in artifact.sections:
192
+ filename = section.filename
193
+ self._write_section_file(artifact_path, section)
194
+ current_files.add(filename)
195
+
196
+ # Remove orphaned section files
197
+ orphaned_files = existing_files - current_files
198
+ for filename in orphaned_files:
199
+ file_path = artifact_path / filename
200
+ try:
201
+ file_path.unlink()
202
+ logger.debug("Removed orphaned section file: %s", file_path)
203
+ except Exception as e:
204
+ logger.warning(
205
+ "Failed to remove orphaned file %s: %s", file_path, e
206
+ )
207
+
208
+ # No metadata to save - all artifact state is in sections and template files
209
+
210
+ except ArtifactNotFoundError:
211
+ raise
212
+ except Exception as e:
213
+ raise ArtifactFileSystemError(
214
+ "save artifact", str(artifact_path), str(e)
215
+ ) from e
216
+
217
+ def delete_artifact(self, agent_mode: AgentMode, artifact_id: str) -> None:
218
+ """Delete an artifact from the file system."""
219
+ artifact_path = self._get_artifact_path(agent_mode, artifact_id)
220
+
221
+ if not artifact_path.exists():
222
+ raise ArtifactNotFoundError(artifact_id, agent_mode.value)
223
+
224
+ try:
225
+ self._remove_directory(artifact_path)
226
+ logger.debug("Deleted artifact directory: %s", artifact_path)
227
+ except Exception as e:
228
+ raise ArtifactFileSystemError(
229
+ "delete artifact", str(artifact_path), str(e)
230
+ ) from e
231
+
232
+ def list_artifacts(
233
+ self, agent_mode: AgentMode | None = None
234
+ ) -> list[dict[str, Any]]:
235
+ """List all artifacts, optionally filtered by agent mode."""
236
+ artifacts = []
237
+
238
+ try:
239
+ if agent_mode:
240
+ agent_dirs = [self.base_path / agent_mode.value]
241
+ else:
242
+ agent_dirs = [
243
+ self.base_path / mode.value
244
+ for mode in AgentMode
245
+ if (self.base_path / mode.value).exists()
246
+ ]
247
+
248
+ for agent_dir in agent_dirs:
249
+ if not agent_dir.exists():
250
+ continue
251
+
252
+ mode = AgentMode(agent_dir.name)
253
+ for artifact_dir in agent_dir.iterdir():
254
+ if artifact_dir.is_dir() and not artifact_dir.name.startswith("."):
255
+ try:
256
+ section_titles = self._get_section_titles(artifact_dir)
257
+
258
+ artifacts.append(
259
+ {
260
+ "artifact_id": artifact_dir.name,
261
+ "agent_mode": mode,
262
+ "section_count": len(section_titles),
263
+ "section_titles": section_titles,
264
+ "created_at": self._get_artifact_created_at(
265
+ artifact_dir
266
+ ),
267
+ "updated_at": self._get_artifact_updated_at(
268
+ artifact_dir
269
+ ),
270
+ "description": "", # No longer stored
271
+ }
272
+ )
273
+ except Exception as e:
274
+ logger.warning(
275
+ "Failed to load artifact info from %s: %s",
276
+ artifact_dir,
277
+ e,
278
+ )
279
+ continue
280
+
281
+ except Exception as e:
282
+ raise ArtifactFileSystemError(
283
+ "list artifacts", str(self.base_path), str(e)
284
+ ) from e
285
+
286
+ return artifacts
287
+
288
+ def _write_section_file(
289
+ self, artifact_path: Path, section: ArtifactSection
290
+ ) -> None:
291
+ """Write a section to its markdown file."""
292
+ section_path = artifact_path / section.filename
293
+ try:
294
+ content = f"# {section.title}\n\n{section.content}"
295
+ section_path.write_text(content, encoding="utf-8")
296
+ logger.debug("Wrote section file: %s", section_path)
297
+ except Exception as e:
298
+ raise ArtifactFileSystemError(
299
+ "write section", str(section_path), str(e)
300
+ ) from e
301
+
302
+ def _load_section_from_file(self, section_file: Path) -> ArtifactSection | None:
303
+ """Load a section from a markdown file."""
304
+ try:
305
+ # Parse filename for number and slug
306
+ filename = section_file.name
307
+ if not filename.endswith(".md"):
308
+ return None
309
+
310
+ name_part = filename[:-3] # Remove .md
311
+ if not name_part or "-" not in name_part:
312
+ return None
313
+
314
+ try:
315
+ number_str, slug = name_part.split("-", 1)
316
+ number = int(number_str)
317
+ except (ValueError, IndexError):
318
+ logger.warning("Invalid section filename format: %s", filename)
319
+ return None
320
+
321
+ # Read content
322
+ content = section_file.read_text(encoding="utf-8")
323
+
324
+ # Extract title from first heading if present
325
+ lines = content.split("\n")
326
+ title = slug.replace("-", " ").title() # Default title
327
+ content_lines = lines
328
+
329
+ if lines and lines[0].startswith("# "):
330
+ title = lines[0][2:].strip()
331
+ content_lines = (
332
+ lines[2:] if len(lines) > 1 and not lines[1].strip() else lines[1:]
333
+ )
334
+
335
+ content_text = "\n".join(content_lines).strip()
336
+
337
+ return ArtifactSection(
338
+ number=number,
339
+ slug=slug,
340
+ title=title,
341
+ content=content_text,
342
+ )
343
+
344
+ except Exception as e:
345
+ logger.warning("Failed to load section from %s: %s", section_file, e)
346
+ return None
347
+
348
+ def _get_section_titles(self, artifact_path: Path) -> list[str]:
349
+ """Get list of section titles from artifact directory."""
350
+ titles = []
351
+ section_files = sorted(artifact_path.glob("*.md"))
352
+
353
+ for section_file in section_files:
354
+ section = self._load_section_from_file(section_file)
355
+ if section:
356
+ titles.append(section.title)
357
+
358
+ return titles
359
+
360
+ def _ensure_artifact_exists(self, agent_mode: AgentMode, artifact_id: str) -> Path:
361
+ """Ensure artifact exists and return its path.
362
+
363
+ Args:
364
+ agent_mode: Agent mode
365
+ artifact_id: Artifact ID
366
+
367
+ Returns:
368
+ Path to the artifact directory
369
+
370
+ Raises:
371
+ ArtifactNotFoundError: If artifact doesn't exist
372
+ """
373
+ artifact_path = self._get_artifact_path(agent_mode, artifact_id)
374
+ if not artifact_path.exists():
375
+ raise ArtifactNotFoundError(artifact_id, agent_mode.value)
376
+ return artifact_path
377
+
378
+ def _find_section_file_by_number(
379
+ self, artifact_path: Path, section_number: int
380
+ ) -> Path:
381
+ """Find section file by number.
382
+
383
+ Args:
384
+ artifact_path: Path to artifact directory
385
+ section_number: Section number to find
386
+
387
+ Returns:
388
+ Path to the section file
389
+
390
+ Raises:
391
+ SectionNotFoundError: If section file doesn't exist
392
+ """
393
+ for file in artifact_path.glob("*.md"):
394
+ filename = file.name
395
+ if filename.startswith(f"{section_number:03d}-"):
396
+ return file
397
+
398
+ raise SectionNotFoundError(section_number, artifact_path.name)
399
+
400
+ def _remove_directory(self, path: Path) -> None:
401
+ """Recursively remove a directory."""
402
+ if not path.exists():
403
+ return
404
+
405
+ for item in path.iterdir():
406
+ if item.is_dir():
407
+ self._remove_directory(item)
408
+ else:
409
+ item.unlink()
410
+
411
+ path.rmdir()
412
+
413
+ def _get_artifact_created_at(self, artifact_path: Path) -> datetime:
414
+ """Get artifact creation time from filesystem."""
415
+ if artifact_path.exists():
416
+ return datetime.fromtimestamp(artifact_path.stat().st_ctime)
417
+ return datetime.now()
418
+
419
+ def _get_artifact_updated_at(self, artifact_path: Path) -> datetime:
420
+ """Get artifact last updated time from filesystem."""
421
+ if not artifact_path.exists():
422
+ return datetime.now()
423
+
424
+ # Find the most recently modified file in the artifact directory
425
+ most_recent = artifact_path.stat().st_mtime
426
+ for file_path in artifact_path.rglob("*"):
427
+ if file_path.is_file():
428
+ file_mtime = file_path.stat().st_mtime
429
+ most_recent = max(most_recent, file_mtime)
430
+
431
+ return datetime.fromtimestamp(most_recent)
432
+
433
+ def save_template_file(
434
+ self, agent_mode: AgentMode, artifact_id: str, template_content: dict[str, Any]
435
+ ) -> None:
436
+ """Save template content to an artifact's .template.yaml file.
437
+
438
+ Args:
439
+ agent_mode: Agent mode
440
+ artifact_id: Artifact ID
441
+ template_content: Template content as dictionary
442
+
443
+ Raises:
444
+ ArtifactNotFoundError: If artifact doesn't exist
445
+ ArtifactFileSystemError: If template file cannot be saved
446
+ """
447
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
448
+ self._save_template(artifact_path, template_content)
449
+
450
+ def write_section(
451
+ self, agent_mode: AgentMode, artifact_id: str, section: ArtifactSection
452
+ ) -> None:
453
+ """Write a single section to its file without affecting other sections.
454
+
455
+ Args:
456
+ agent_mode: Agent mode
457
+ artifact_id: Artifact ID
458
+ section: Section to write
459
+
460
+ Raises:
461
+ ArtifactNotFoundError: If artifact doesn't exist
462
+ ArtifactFileSystemError: If section file cannot be written
463
+ """
464
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
465
+ self._write_section_file(artifact_path, section)
466
+
467
+ def update_section_file(
468
+ self, agent_mode: AgentMode, artifact_id: str, section: ArtifactSection
469
+ ) -> None:
470
+ """Update a single section file without affecting other sections.
471
+
472
+ Args:
473
+ agent_mode: Agent mode
474
+ artifact_id: Artifact ID
475
+ section: Updated section data
476
+
477
+ Raises:
478
+ ArtifactNotFoundError: If artifact doesn't exist
479
+ SectionNotFoundError: If section file doesn't exist
480
+ ArtifactFileSystemError: If section file cannot be updated
481
+ """
482
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
483
+
484
+ # Verify section file exists
485
+ section_path = artifact_path / section.filename
486
+ if not section_path.exists():
487
+ raise SectionNotFoundError(section.number, artifact_id)
488
+
489
+ self._write_section_file(artifact_path, section)
490
+
491
+ def delete_section_file(
492
+ self, agent_mode: AgentMode, artifact_id: str, section_number: int
493
+ ) -> None:
494
+ """Delete a single section file without affecting other sections.
495
+
496
+ Args:
497
+ agent_mode: Agent mode
498
+ artifact_id: Artifact ID
499
+ section_number: Section number to delete
500
+
501
+ Raises:
502
+ ArtifactNotFoundError: If artifact doesn't exist
503
+ SectionNotFoundError: If section file doesn't exist
504
+ ArtifactFileSystemError: If section file cannot be deleted
505
+ """
506
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
507
+ section_file = self._find_section_file_by_number(artifact_path, section_number)
508
+
509
+ try:
510
+ section_file.unlink()
511
+ logger.debug("Deleted section file: %s", section_file)
512
+ except Exception as e:
513
+ raise ArtifactFileSystemError(
514
+ "delete section", str(section_file), str(e)
515
+ ) from e
516
+
517
+ def read_section(
518
+ self, agent_mode: AgentMode, artifact_id: str, section_number: int
519
+ ) -> str:
520
+ """Read content of a specific section."""
521
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
522
+ section_file = self._find_section_file_by_number(artifact_path, section_number)
523
+
524
+ try:
525
+ return section_file.read_text(encoding="utf-8")
526
+ except Exception as e:
527
+ raise ArtifactFileSystemError(
528
+ "read section", str(section_file), str(e)
529
+ ) from e