shotgun-sh 0.1.0.dev12__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.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/METADATA +2 -2
  45. {shotgun_sh-0.1.0.dev12.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.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/WHEEL +0 -0
  48. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/entry_points.txt +0 -0
  49. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,332 @@
1
+ """Pydantic models for the artifact system."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field, field_validator, model_validator
10
+
11
+
12
+ class AgentMode(str, Enum):
13
+ """Supported agent modes for artifacts."""
14
+
15
+ RESEARCH = "research"
16
+ PLAN = "plan"
17
+ TASKS = "tasks"
18
+ SPECIFY = "specify"
19
+
20
+
21
+ class ArtifactSection(BaseModel):
22
+ """A section within an artifact."""
23
+
24
+ number: int = Field(..., ge=1, description="Section number for ordering")
25
+ slug: str = Field(..., min_length=1, description="URL-friendly section identifier")
26
+ title: str = Field(..., min_length=1, description="Human-readable section title")
27
+ content: str = Field(default="", description="Markdown content of the section")
28
+
29
+ @field_validator("slug")
30
+ @classmethod
31
+ def validate_slug(cls, v: str) -> str:
32
+ """Validate that slug contains only valid characters."""
33
+ if not re.match(r"^[a-z0-9._-]+$", v):
34
+ raise ValueError(
35
+ "Slug must contain only lowercase letters, numbers, hyphens, dots, and underscores"
36
+ )
37
+ if v.startswith("-") or v.endswith("-"):
38
+ raise ValueError("Slug cannot start or end with hyphen")
39
+ if v.startswith(".") or v.endswith("."):
40
+ raise ValueError("Slug cannot start or end with dot")
41
+ if v.startswith("_") or v.endswith("_"):
42
+ raise ValueError("Slug cannot start or end with underscore")
43
+ if "--" in v:
44
+ raise ValueError("Slug cannot contain consecutive hyphens")
45
+ if ".." in v:
46
+ raise ValueError("Slug cannot contain consecutive dots")
47
+ if "__" in v:
48
+ raise ValueError("Slug cannot contain consecutive underscores")
49
+ return v
50
+
51
+ @property
52
+ def filename(self) -> str:
53
+ """Generate filename for this section."""
54
+ return f"{self.number:03d}-{self.slug}.md"
55
+
56
+ def __str__(self) -> str:
57
+ """String representation for debugging."""
58
+ return f"Section({self.number:03d}-{self.slug})"
59
+
60
+
61
+ class Artifact(BaseModel):
62
+ """Main artifact model containing sections."""
63
+
64
+ artifact_id: str = Field(
65
+ ..., min_length=1, description="Unique artifact identifier"
66
+ )
67
+ agent_mode: AgentMode = Field(
68
+ ..., description="Agent mode this artifact belongs to"
69
+ )
70
+ name: str = Field(..., min_length=1, description="Human-readable artifact name")
71
+ sections: list[ArtifactSection] = Field(default_factory=list)
72
+
73
+ @field_validator("artifact_id")
74
+ @classmethod
75
+ def validate_artifact_id(cls, v: str) -> str:
76
+ """Validate that artifact_id is a valid slug."""
77
+ if not re.match(r"^[a-z0-9-]+$", v):
78
+ raise ValueError(
79
+ "Artifact ID must contain only lowercase letters, numbers, and hyphens"
80
+ )
81
+ if v.startswith("-") or v.endswith("-"):
82
+ raise ValueError("Artifact ID cannot start or end with hyphen")
83
+ if "--" in v:
84
+ raise ValueError("Artifact ID cannot contain consecutive hyphens")
85
+ return v
86
+
87
+ @model_validator(mode="after")
88
+ def validate_section_numbers(self) -> "Artifact":
89
+ """Ensure section numbers are unique and sequential."""
90
+ if not self.sections:
91
+ return self
92
+
93
+ numbers = [section.number for section in self.sections]
94
+ if len(numbers) != len(set(numbers)):
95
+ raise ValueError("Section numbers must be unique")
96
+
97
+ # Check for reasonable numbering (starting from 1, no big gaps)
98
+ sorted_numbers = sorted(numbers)
99
+ if sorted_numbers[0] != 1:
100
+ raise ValueError("Section numbers should start from 1")
101
+
102
+ # Allow gaps but warn about unreasonable ones
103
+ max_gap = (
104
+ max(
105
+ sorted_numbers[i + 1] - sorted_numbers[i]
106
+ for i in range(len(sorted_numbers) - 1)
107
+ )
108
+ if len(sorted_numbers) > 1
109
+ else 0
110
+ )
111
+
112
+ if max_gap > 10:
113
+ # This is just a warning, not an error
114
+ pass
115
+
116
+ return self
117
+
118
+ @property
119
+ def directory_path(self) -> str:
120
+ """Get the relative directory path for this artifact."""
121
+ return f"{self.agent_mode.value}/{self.artifact_id}"
122
+
123
+ def get_section_by_number(self, number: int) -> ArtifactSection | None:
124
+ """Get section by number."""
125
+ for section in self.sections:
126
+ if section.number == number:
127
+ return section
128
+ return None
129
+
130
+ def get_section_by_slug(self, slug: str) -> ArtifactSection | None:
131
+ """Get section by slug."""
132
+ for section in self.sections:
133
+ if section.slug == slug:
134
+ return section
135
+ return None
136
+
137
+ def add_section(self, section: ArtifactSection) -> None:
138
+ """Add a section to the artifact."""
139
+ # Check for conflicts
140
+ if self.get_section_by_number(section.number):
141
+ raise ValueError(f"Section number {section.number} already exists")
142
+ if self.get_section_by_slug(section.slug):
143
+ raise ValueError(f"Section slug '{section.slug}' already exists")
144
+
145
+ self.sections.append(section)
146
+ self.sections.sort(key=lambda s: s.number)
147
+
148
+ def remove_section(self, number: int) -> bool:
149
+ """Remove a section by number. Returns True if section was removed."""
150
+ original_count = len(self.sections)
151
+ self.sections = [s for s in self.sections if s.number != number]
152
+ removed = len(self.sections) < original_count
153
+ return removed
154
+
155
+ def update_section(self, number: int, **kwargs: Any) -> bool:
156
+ """Update a section's fields. Returns True if section was found and updated."""
157
+ section = self.get_section_by_number(number)
158
+ if not section:
159
+ return False
160
+
161
+ # Handle special case of changing number
162
+ if "number" in kwargs and kwargs["number"] != section.number:
163
+ new_number = kwargs["number"]
164
+ if self.get_section_by_number(new_number):
165
+ raise ValueError(f"Section number {new_number} already exists")
166
+
167
+ # Update fields
168
+ for field, value in kwargs.items():
169
+ setattr(section, field, value)
170
+
171
+ # Re-sort if number changed
172
+ if "number" in kwargs:
173
+ self.sections.sort(key=lambda s: s.number)
174
+
175
+ return True
176
+
177
+ def get_ordered_sections(self) -> list[ArtifactSection]:
178
+ """Get sections ordered by number."""
179
+ return sorted(self.sections, key=lambda s: s.number)
180
+
181
+ def has_template(self, base_path: Path | None = None) -> bool:
182
+ """Check if this artifact was created from a template."""
183
+ if base_path is None:
184
+ base_path = Path.cwd() / ".shotgun"
185
+ elif isinstance(base_path, str):
186
+ base_path = Path(base_path)
187
+
188
+ template_path = (
189
+ base_path / self.agent_mode.value / self.artifact_id / ".template.yaml"
190
+ )
191
+ return template_path.exists()
192
+
193
+ def get_template_id(self, base_path: Path | None = None) -> str | None:
194
+ """Get the template ID from the template file."""
195
+ template_content = self.load_template_from_file(base_path)
196
+ if template_content and "template_id" in template_content:
197
+ template_id = template_content["template_id"]
198
+ return str(template_id) if template_id is not None else None
199
+ return None
200
+
201
+ def load_template_from_file(
202
+ self, base_path: Path | None = None
203
+ ) -> dict[str, Any] | None:
204
+ """Load template content from the artifact's .template.yaml file.
205
+
206
+ Args:
207
+ base_path: Base path for artifacts. Defaults to .shotgun in current directory.
208
+
209
+ Returns:
210
+ Template content as dict or None if no template file exists.
211
+ """
212
+ import yaml
213
+
214
+ if base_path is None:
215
+ base_path = Path.cwd() / ".shotgun"
216
+ elif isinstance(base_path, str):
217
+ base_path = Path(base_path)
218
+
219
+ template_path = (
220
+ base_path / self.agent_mode.value / self.artifact_id / ".template.yaml"
221
+ )
222
+
223
+ if not template_path.exists():
224
+ return None
225
+
226
+ try:
227
+ with open(template_path, encoding="utf-8") as f:
228
+ data = yaml.safe_load(f)
229
+ return data if isinstance(data, dict) else None
230
+ except Exception:
231
+ return None
232
+
233
+ def get_section_count(self) -> int:
234
+ """Get section count based on current sections."""
235
+ return len(self.sections)
236
+
237
+ def get_total_content_length(self) -> int:
238
+ """Calculate total content length from all sections."""
239
+ return sum(len(section.content) for section in self.sections)
240
+
241
+ def get_created_at(self, base_path: Path | None = None) -> datetime:
242
+ """Get artifact creation time from filesystem.
243
+
244
+ Args:
245
+ base_path: Base path for artifacts. Defaults to .shotgun in current directory.
246
+
247
+ Returns:
248
+ Creation timestamp based on artifact directory creation time.
249
+ """
250
+ if base_path is None:
251
+ base_path = Path.cwd() / ".shotgun"
252
+ elif isinstance(base_path, str):
253
+ base_path = Path(base_path)
254
+
255
+ artifact_path = base_path / self.agent_mode.value / self.artifact_id
256
+ if artifact_path.exists():
257
+ return datetime.fromtimestamp(artifact_path.stat().st_ctime)
258
+ return datetime.now()
259
+
260
+ def get_updated_at(self, base_path: Path | None = None) -> datetime:
261
+ """Get artifact last updated time from filesystem.
262
+
263
+ Args:
264
+ base_path: Base path for artifacts. Defaults to .shotgun in current directory.
265
+
266
+ Returns:
267
+ Last modified timestamp based on most recently modified file in artifact directory.
268
+ """
269
+ if base_path is None:
270
+ base_path = Path.cwd() / ".shotgun"
271
+ elif isinstance(base_path, str):
272
+ base_path = Path(base_path)
273
+
274
+ artifact_path = base_path / self.agent_mode.value / self.artifact_id
275
+ if not artifact_path.exists():
276
+ return datetime.now()
277
+
278
+ # Find the most recently modified file in the artifact directory
279
+ most_recent = artifact_path.stat().st_mtime
280
+ for file_path in artifact_path.rglob("*"):
281
+ if file_path.is_file():
282
+ file_mtime = file_path.stat().st_mtime
283
+ most_recent = max(most_recent, file_mtime)
284
+
285
+ return datetime.fromtimestamp(most_recent)
286
+
287
+ def __str__(self) -> str:
288
+ """String representation for debugging."""
289
+ template_info = " (template)" if self.has_template() else ""
290
+ return f"Artifact({self.agent_mode.value}/{self.artifact_id}, {len(self.sections)} sections{template_info})"
291
+
292
+
293
+ class ArtifactSummary(BaseModel):
294
+ """Summary information about an artifact without full content."""
295
+
296
+ artifact_id: str
297
+ agent_mode: AgentMode
298
+ name: str
299
+ section_count: int
300
+ created_at: datetime
301
+ updated_at: datetime
302
+ section_titles: list[str] = Field(default_factory=list)
303
+ template_id: str | None = Field(
304
+ default=None, description="ID of template used to create this artifact"
305
+ )
306
+
307
+ @classmethod
308
+ def from_artifact(
309
+ cls, artifact: Artifact, base_path: Path | None = None
310
+ ) -> "ArtifactSummary":
311
+ """Create summary from full artifact.
312
+
313
+ Args:
314
+ artifact: The artifact to create summary from
315
+ base_path: Base path for artifacts. Used for filesystem-based timestamps.
316
+ """
317
+ return cls(
318
+ artifact_id=artifact.artifact_id,
319
+ agent_mode=artifact.agent_mode,
320
+ name=artifact.name,
321
+ section_count=artifact.get_section_count(),
322
+ created_at=artifact.get_created_at(base_path),
323
+ updated_at=artifact.get_updated_at(base_path),
324
+ section_titles=[
325
+ section.title for section in artifact.get_ordered_sections()
326
+ ],
327
+ template_id=artifact.get_template_id(base_path),
328
+ )
329
+
330
+ def __str__(self) -> str:
331
+ """String representation for debugging."""
332
+ return f"ArtifactSummary({self.agent_mode.value}/{self.artifact_id})"