shotgun-sh 0.2.7.dev1__py3-none-any.whl → 0.2.8.dev1__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.

@@ -4,9 +4,17 @@ import json
4
4
  import logging
5
5
  from collections.abc import AsyncIterable, Sequence
6
6
  from dataclasses import dataclass, field, is_dataclass, replace
7
+ from pathlib import Path
7
8
  from typing import TYPE_CHECKING, Any, cast
8
9
 
9
10
  import logfire
11
+ from tenacity import (
12
+ before_sleep_log,
13
+ retry,
14
+ retry_if_exception,
15
+ stop_after_attempt,
16
+ wait_exponential,
17
+ )
10
18
 
11
19
  if TYPE_CHECKING:
12
20
  from shotgun.agents.conversation_history import ConversationState
@@ -55,6 +63,35 @@ from .tasks import create_tasks_agent
55
63
  logger = logging.getLogger(__name__)
56
64
 
57
65
 
66
+ def _is_retryable_error(exception: BaseException) -> bool:
67
+ """Check if exception should trigger a retry.
68
+
69
+ Args:
70
+ exception: The exception to check.
71
+
72
+ Returns:
73
+ True if the exception is a transient error that should be retried.
74
+ """
75
+ # ValueError for truncated/incomplete JSON
76
+ if isinstance(exception, ValueError):
77
+ error_str = str(exception)
78
+ return "EOF while parsing" in error_str or (
79
+ "JSON" in error_str and "parsing" in error_str
80
+ )
81
+
82
+ # API errors (overload, rate limits)
83
+ exception_name = type(exception).__name__
84
+ if "APIStatusError" in exception_name:
85
+ error_str = str(exception)
86
+ return "overload" in error_str.lower() or "rate" in error_str.lower()
87
+
88
+ # Network errors
89
+ if "ConnectionError" in exception_name or "TimeoutError" in exception_name:
90
+ return True
91
+
92
+ return False
93
+
94
+
58
95
  class MessageHistoryUpdated(Message):
59
96
  """Event posted when the message history is updated."""
60
97
 
@@ -268,6 +305,49 @@ class AgentManager(Widget):
268
305
  f"Invalid agent type: {agent_type}. Must be one of: {', '.join(e.value for e in AgentType)}"
269
306
  ) from None
270
307
 
308
+ @retry(
309
+ stop=stop_after_attempt(3),
310
+ wait=wait_exponential(multiplier=1, min=1, max=8),
311
+ retry=retry_if_exception(_is_retryable_error),
312
+ before_sleep=before_sleep_log(logger, logging.WARNING),
313
+ reraise=True,
314
+ )
315
+ async def _run_agent_with_retry(
316
+ self,
317
+ agent: Agent[AgentDeps, AgentResponse],
318
+ prompt: str | None,
319
+ deps: AgentDeps,
320
+ usage_limits: UsageLimits | None,
321
+ message_history: list[ModelMessage],
322
+ event_stream_handler: Any,
323
+ **kwargs: Any,
324
+ ) -> AgentRunResult[AgentResponse]:
325
+ """Run agent with automatic retry on transient errors.
326
+
327
+ Args:
328
+ agent: The agent to run.
329
+ prompt: Optional prompt to send to the agent.
330
+ deps: Agent dependencies.
331
+ usage_limits: Optional usage limits.
332
+ message_history: Message history to provide to agent.
333
+ event_stream_handler: Event handler for streaming.
334
+ **kwargs: Additional keyword arguments.
335
+
336
+ Returns:
337
+ The agent run result.
338
+
339
+ Raises:
340
+ Various exceptions if all retries fail.
341
+ """
342
+ return await agent.run(
343
+ prompt,
344
+ deps=deps,
345
+ usage_limits=usage_limits,
346
+ message_history=message_history,
347
+ event_stream_handler=event_stream_handler,
348
+ **kwargs,
349
+ )
350
+
271
351
  async def run(
272
352
  self,
273
353
  prompt: str | None = None,
@@ -394,8 +474,9 @@ class AgentManager(Widget):
394
474
  )
395
475
 
396
476
  try:
397
- result: AgentRunResult[AgentResponse] = await self.current_agent.run(
398
- prompt,
477
+ result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
478
+ agent=self.current_agent,
479
+ prompt=prompt,
399
480
  deps=deps,
400
481
  usage_limits=usage_limits,
401
482
  message_history=message_history,
@@ -404,6 +485,36 @@ class AgentManager(Widget):
404
485
  else None,
405
486
  **kwargs,
406
487
  )
488
+ except ValueError as e:
489
+ # Handle truncated/incomplete JSON in tool calls specifically
490
+ error_str = str(e)
491
+ if "EOF while parsing" in error_str or (
492
+ "JSON" in error_str and "parsing" in error_str
493
+ ):
494
+ logger.error(
495
+ "Tool call with truncated/incomplete JSON arguments detected",
496
+ extra={
497
+ "agent_mode": self._current_agent_type.value,
498
+ "model_name": model_name,
499
+ "error": error_str,
500
+ },
501
+ )
502
+ logfire.error(
503
+ "Tool call with truncated JSON arguments",
504
+ agent_mode=self._current_agent_type.value,
505
+ model_name=model_name,
506
+ error=error_str,
507
+ )
508
+ # Add helpful hint message for the user
509
+ self.ui_message_history.append(
510
+ HintMessage(
511
+ message="⚠️ The agent attempted an operation with arguments that were too large (truncated JSON). "
512
+ "Try breaking your request into smaller steps or more focused contracts."
513
+ )
514
+ )
515
+ self._post_messages_updated()
516
+ # Re-raise to maintain error visibility
517
+ raise
407
518
  except Exception as e:
408
519
  # Log the error with full stack trace to shotgun.log and Logfire
409
520
  logger.exception(
@@ -427,13 +538,40 @@ class AgentManager(Widget):
427
538
 
428
539
  # Agent ALWAYS returns AgentResponse with structured output
429
540
  agent_response = result.output
430
- logger.debug("Agent returned structured AgentResponse")
541
+ logger.debug(
542
+ "Agent returned structured AgentResponse",
543
+ extra={
544
+ "has_response": agent_response.response is not None,
545
+ "response_length": len(agent_response.response)
546
+ if agent_response.response
547
+ else 0,
548
+ "response_preview": agent_response.response[:100] + "..."
549
+ if agent_response.response and len(agent_response.response) > 100
550
+ else agent_response.response or "(empty)",
551
+ "has_clarifying_questions": bool(agent_response.clarifying_questions),
552
+ "num_clarifying_questions": len(agent_response.clarifying_questions)
553
+ if agent_response.clarifying_questions
554
+ else 0,
555
+ },
556
+ )
431
557
 
432
558
  # Always add the agent's response messages to maintain conversation history
433
559
  self.ui_message_history = original_messages + cast(
434
560
  list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
435
561
  )
436
562
 
563
+ # Get file operations early so we can use them for contextual messages
564
+ file_operations = deps.file_tracker.operations.copy()
565
+ self.recently_change_files = file_operations
566
+
567
+ logger.debug(
568
+ "File operations tracked",
569
+ extra={
570
+ "num_file_operations": len(file_operations),
571
+ "operation_files": [Path(op.file_path).name for op in file_operations],
572
+ },
573
+ )
574
+
437
575
  # Check if there are clarifying questions
438
576
  if agent_response.clarifying_questions:
439
577
  logger.info(
@@ -480,12 +618,50 @@ class AgentManager(Widget):
480
618
  response_text=agent_response.response,
481
619
  )
482
620
  )
621
+
622
+ # Post UI update with hint messages and file operations
623
+ logger.debug(
624
+ "Posting UI update for Q&A mode with hint messages and file operations"
625
+ )
626
+ self._post_messages_updated(file_operations)
483
627
  else:
484
- # No clarifying questions - just show the response if present
628
+ # No clarifying questions - show the response or a default success message
485
629
  if agent_response.response and agent_response.response.strip():
630
+ logger.debug(
631
+ "Adding agent response as hint",
632
+ extra={
633
+ "response_preview": agent_response.response[:100] + "..."
634
+ if len(agent_response.response) > 100
635
+ else agent_response.response,
636
+ "has_file_operations": len(file_operations) > 0,
637
+ },
638
+ )
486
639
  self.ui_message_history.append(
487
640
  HintMessage(message=agent_response.response)
488
641
  )
642
+ else:
643
+ # Fallback: response is empty or whitespace
644
+ logger.debug(
645
+ "Agent response was empty, using fallback completion message",
646
+ extra={"has_file_operations": len(file_operations) > 0},
647
+ )
648
+ # Show contextual message based on whether files were modified
649
+ if file_operations:
650
+ self.ui_message_history.append(
651
+ HintMessage(
652
+ message="✅ Task completed - files have been modified"
653
+ )
654
+ )
655
+ else:
656
+ self.ui_message_history.append(
657
+ HintMessage(message="✅ Task completed")
658
+ )
659
+
660
+ # Post UI update immediately so user sees the response without delay
661
+ logger.debug(
662
+ "Posting immediate UI update with hint message and file operations"
663
+ )
664
+ self._post_messages_updated(file_operations)
489
665
 
490
666
  # Apply compaction to persistent message history to prevent cascading growth
491
667
  all_messages = result.all_messages()
@@ -517,16 +693,18 @@ class AgentManager(Widget):
517
693
  self.message_history = all_messages
518
694
 
519
695
  usage = result.usage()
520
- deps.usage_manager.add_usage(
521
- usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
522
- )
523
-
524
- # Log file operations summary if any files were modified
525
- file_operations = deps.file_tracker.operations.copy()
526
- self.recently_change_files = file_operations
696
+ if hasattr(deps, "llm_model") and deps.llm_model is not None:
697
+ deps.usage_manager.add_usage(
698
+ usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
699
+ )
700
+ else:
701
+ logger.warning(
702
+ "llm_model is None, skipping usage tracking",
703
+ extra={"agent_mode": self._current_agent_type.value},
704
+ )
527
705
 
528
- # Post message history update (hints are now added synchronously above)
529
- self._post_messages_updated(file_operations)
706
+ # UI updates are now posted immediately in each branch (Q&A or non-Q&A)
707
+ # before compaction, so no duplicate posting needed here
530
708
 
531
709
  return result
532
710
 
shotgun/agents/common.py CHANGED
@@ -384,23 +384,48 @@ def get_agent_existing_files(agent_mode: AgentType | None = None) -> list[str]:
384
384
  relative_path = file_path.relative_to(base_path)
385
385
  existing_files.append(str(relative_path))
386
386
  else:
387
- # For other agents, check both .md file and directory with same name
388
- allowed_file = AGENT_DIRECTORIES[agent_mode]
389
-
390
- # Check for the .md file
391
- md_file_path = base_path / allowed_file
392
- if md_file_path.exists():
393
- existing_files.append(allowed_file)
394
-
395
- # Check for directory with same base name (e.g., research/ for research.md)
396
- base_name = allowed_file.replace(".md", "")
397
- dir_path = base_path / base_name
398
- if dir_path.exists() and dir_path.is_dir():
399
- # List all files in the directory
400
- for file_path in dir_path.rglob("*"):
401
- if file_path.is_file():
402
- relative_path = file_path.relative_to(base_path)
403
- existing_files.append(str(relative_path))
387
+ # For other agents, check files/directories they have access to
388
+ allowed_paths_raw = AGENT_DIRECTORIES[agent_mode]
389
+
390
+ # Convert single Path/string to list of Paths for uniform handling
391
+ if isinstance(allowed_paths_raw, str):
392
+ # Special case: "*" means export agent (shouldn't reach here but handle it)
393
+ allowed_paths = (
394
+ [Path(allowed_paths_raw)] if allowed_paths_raw != "*" else []
395
+ )
396
+ elif isinstance(allowed_paths_raw, Path):
397
+ allowed_paths = [allowed_paths_raw]
398
+ else:
399
+ # Already a list
400
+ allowed_paths = allowed_paths_raw
401
+
402
+ # Check each allowed path
403
+ for allowed_path in allowed_paths:
404
+ allowed_str = str(allowed_path)
405
+
406
+ # Check if it's a directory (no .md suffix)
407
+ if not allowed_path.suffix or not allowed_str.endswith(".md"):
408
+ # It's a directory - list all files within it
409
+ dir_path = base_path / allowed_str
410
+ if dir_path.exists() and dir_path.is_dir():
411
+ for file_path in dir_path.rglob("*"):
412
+ if file_path.is_file():
413
+ relative_path = file_path.relative_to(base_path)
414
+ existing_files.append(str(relative_path))
415
+ else:
416
+ # It's a file - check if it exists
417
+ file_path = base_path / allowed_str
418
+ if file_path.exists():
419
+ existing_files.append(allowed_str)
420
+
421
+ # Also check for associated directory (e.g., research/ for research.md)
422
+ base_name = allowed_str.replace(".md", "")
423
+ dir_path = base_path / base_name
424
+ if dir_path.exists() and dir_path.is_dir():
425
+ for file_path in dir_path.rglob("*"):
426
+ if file_path.is_file():
427
+ relative_path = file_path.relative_to(base_path)
428
+ existing_files.append(str(relative_path))
404
429
 
405
430
  return existing_files
406
431
 
@@ -15,11 +15,18 @@ from shotgun.utils.file_system_utils import get_shotgun_base_path
15
15
  logger = get_logger(__name__)
16
16
 
17
17
  # Map agent modes to their allowed directories/files (in workflow order)
18
- AGENT_DIRECTORIES = {
19
- AgentType.RESEARCH: "research.md",
20
- AgentType.SPECIFY: "specification.md",
21
- AgentType.PLAN: "plan.md",
22
- AgentType.TASKS: "tasks.md",
18
+ # Values can be:
19
+ # - A Path: exact file (e.g., Path("research.md"))
20
+ # - A list of Paths: multiple allowed files/directories (e.g., [Path("specification.md"), Path("contracts")])
21
+ # - "*": any file except protected files (for export agent)
22
+ AGENT_DIRECTORIES: dict[AgentType, str | Path | list[Path]] = {
23
+ AgentType.RESEARCH: Path("research.md"),
24
+ AgentType.SPECIFY: [
25
+ Path("specification.md"),
26
+ Path("contracts"),
27
+ ], # Specify can write specs and contract files
28
+ AgentType.PLAN: Path("plan.md"),
29
+ AgentType.TASKS: Path("tasks.md"),
23
30
  AgentType.EXPORT: "*", # Export agent can write anywhere except protected files
24
31
  }
25
32
 
@@ -60,13 +67,52 @@ def _validate_agent_scoped_path(filename: str, agent_mode: AgentType | None) ->
60
67
  # Allow writing anywhere else in .shotgun directory
61
68
  full_path = (base_path / filename).resolve()
62
69
  else:
63
- # For other agents, only allow writing to their specific file
64
- allowed_file = AGENT_DIRECTORIES[agent_mode]
65
- if filename != allowed_file:
70
+ # For other agents, check if they have access to the requested file
71
+ allowed_paths_raw = AGENT_DIRECTORIES[agent_mode]
72
+
73
+ # Convert single Path/string to list of Paths for uniform handling
74
+ if isinstance(allowed_paths_raw, str):
75
+ # Special case: "*" means export agent
76
+ allowed_paths = (
77
+ [Path(allowed_paths_raw)] if allowed_paths_raw != "*" else []
78
+ )
79
+ elif isinstance(allowed_paths_raw, Path):
80
+ allowed_paths = [allowed_paths_raw]
81
+ else:
82
+ # Already a list
83
+ allowed_paths = allowed_paths_raw
84
+
85
+ # Check if filename matches any allowed path
86
+ is_allowed = False
87
+ for allowed_path in allowed_paths:
88
+ allowed_str = str(allowed_path)
89
+
90
+ # Check if it's a directory (no .md extension or suffix)
91
+ # Directories: Path("contracts") has no suffix, files: Path("spec.md") has .md suffix
92
+ if not allowed_path.suffix or (
93
+ allowed_path.suffix and not allowed_str.endswith(".md")
94
+ ):
95
+ # Directory - allow any file within this directory
96
+ # Check both "contracts/file.py" and "contracts" prefix
97
+ if (
98
+ filename.startswith(allowed_str + "/")
99
+ or filename == allowed_str
100
+ ):
101
+ is_allowed = True
102
+ break
103
+ else:
104
+ # Exact file match
105
+ if filename == allowed_str:
106
+ is_allowed = True
107
+ break
108
+
109
+ if not is_allowed:
110
+ allowed_display = ", ".join(f"'{p}'" for p in allowed_paths)
66
111
  raise ValueError(
67
- f"{agent_mode.value.capitalize()} agent can only write to '{allowed_file}'. "
112
+ f"{agent_mode.value.capitalize()} agent can only write to {allowed_display}. "
68
113
  f"Attempted to write to '{filename}'"
69
114
  )
115
+
70
116
  full_path = (base_path / filename).resolve()
71
117
  else:
72
118
  # No agent mode specified, fall back to old validation
shotgun/main.py CHANGED
@@ -125,6 +125,34 @@ def main(
125
125
  help="Continue previous TUI conversation",
126
126
  ),
127
127
  ] = False,
128
+ web: Annotated[
129
+ bool,
130
+ typer.Option(
131
+ "--web",
132
+ help="Serve TUI as web application",
133
+ ),
134
+ ] = False,
135
+ port: Annotated[
136
+ int,
137
+ typer.Option(
138
+ "--port",
139
+ help="Port for web server (only used with --web)",
140
+ ),
141
+ ] = 8000,
142
+ host: Annotated[
143
+ str,
144
+ typer.Option(
145
+ "--host",
146
+ help="Host address for web server (only used with --web)",
147
+ ),
148
+ ] = "localhost",
149
+ public_url: Annotated[
150
+ str | None,
151
+ typer.Option(
152
+ "--public-url",
153
+ help="Public URL if behind proxy (only used with --web)",
154
+ ),
155
+ ] = None,
128
156
  ) -> None:
129
157
  """Shotgun - AI-powered CLI tool."""
130
158
  logger.debug("Starting shotgun CLI application")
@@ -134,16 +162,32 @@ def main(
134
162
  perform_auto_update_async(no_update_check=no_update_check)
135
163
 
136
164
  if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
137
- logger.debug("Launching shotgun TUI application")
138
- try:
139
- tui_app.run(
140
- no_update_check=no_update_check, continue_session=continue_session
141
- )
142
- finally:
143
- # Ensure PostHog is shut down cleanly even if TUI exits unexpectedly
144
- from shotgun.posthog_telemetry import shutdown
145
-
146
- shutdown()
165
+ if web:
166
+ logger.debug("Launching shotgun TUI as web application")
167
+ try:
168
+ tui_app.serve(
169
+ host=host,
170
+ port=port,
171
+ public_url=public_url,
172
+ no_update_check=no_update_check,
173
+ continue_session=continue_session,
174
+ )
175
+ finally:
176
+ # Ensure PostHog is shut down cleanly even if server exits unexpectedly
177
+ from shotgun.posthog_telemetry import shutdown
178
+
179
+ shutdown()
180
+ else:
181
+ logger.debug("Launching shotgun TUI application")
182
+ try:
183
+ tui_app.run(
184
+ no_update_check=no_update_check, continue_session=continue_session
185
+ )
186
+ finally:
187
+ # Ensure PostHog is shut down cleanly even if TUI exits unexpectedly
188
+ from shotgun.posthog_telemetry import shutdown
189
+
190
+ shutdown()
147
191
  raise typer.Exit()
148
192
 
149
193
  # For CLI commands, register PostHog shutdown handler
@@ -8,14 +8,281 @@ Transform requirements into detailed, actionable specifications that development
8
8
 
9
9
  ## MEMORY MANAGEMENT PROTOCOL
10
10
 
11
- - You have exclusive write access to: `specification.md`
11
+ - You have exclusive write access to: `specification.md` and `.shotgun/contracts/*`
12
12
  - SHOULD READ `research.md` for context but CANNOT write to it
13
- - This is your persistent memory store - ALWAYS load it first
13
+ - **specification.md is for PROSE ONLY** - no code, no implementation details, no type definitions
14
+ - **All code goes in .shotgun/contracts/** - types, interfaces, schemas
15
+ - specification.md describes WHAT and WHY, contracts/ show HOW with actual code
16
+ - This is your persistent memory store - ALWAYS load specification.md first
14
17
  - Compress content regularly to stay within context limits
15
- - Keep your file updated as you work - it's your memory across sessions
18
+ - Keep your files updated as you work - they're your memory across sessions
16
19
  - When adding new specifications, review and consolidate overlapping requirements
17
20
  - Structure specifications for easy reference by the next agents
18
21
 
22
+ ## WHAT GOES IN SPECIFICATION.MD
23
+
24
+ specification.md is your prose documentation file. It should contain:
25
+
26
+ **INCLUDE in specification.md:**
27
+ - Requirements and business context (what needs to be built and why)
28
+ - Architecture overview and system design decisions
29
+ - Component descriptions and how they interact
30
+ - User workflows and use cases
31
+ - Directory structure as succinct prose (e.g., "src/ contains main code, tests/ contains test files")
32
+ - Dependencies listed in prose (e.g., "Requires TypeScript 5.0+, React 18, and PostgreSQL")
33
+ - Configuration requirements described (e.g., "App needs database URL and API key in environment")
34
+ - Testing strategies and acceptance criteria
35
+ - References to contract files (e.g., "See contracts/user_models.py for User type definition")
36
+
37
+ **DO NOT INCLUDE in specification.md:**
38
+ - Code blocks, type definitions, or function signatures (those go in contracts/)
39
+ - Implementation details or algorithms (describe behavior instead)
40
+ - Actual configuration files or build manifests (describe what's needed instead)
41
+ - Directory trees or file listings (keep structure descriptions succinct)
42
+
43
+ **When you need to show structure:** Reference contract files instead of inline code.
44
+ Example: "User authentication uses OAuth2. See contracts/auth_types.ts for AuthUser and AuthToken types."
45
+
46
+ ## CONTRACT FILES
47
+
48
+ Contract files define the **interfaces and types** that form contracts between components.
49
+ They contain actual code that shows structure, not prose descriptions.
50
+
51
+ **ONLY put these in `.shotgun/contracts/` (language-agnostic):**
52
+ - **Type definitions ONLY** - Shape and structure, NO behavior or logic:
53
+ - Python: Pydantic models, dataclasses, `typing.Protocol` classes (interface definitions)
54
+ - TypeScript: interfaces, type aliases
55
+ - Rust: struct definitions
56
+ - Java: interfaces, POJOs
57
+ - C++: header files with class/struct declarations
58
+ - Go: interface types, struct definitions
59
+ - **Schema definitions**: API contracts and data schemas
60
+ - OpenAPI/Swagger specs (openapi.json, openapi.yaml)
61
+ - JSON Schema definitions
62
+ - GraphQL schemas
63
+ - Protobuf definitions
64
+ - **Protocol/Interface classes**: Pure interface definitions with method signatures only
65
+ - Python: `class Storage(Protocol): def save(self, data: str) -> None: ...`
66
+ - Use `...` (Ellipsis) for protocol methods, NOT `pass`
67
+
68
+ **NEVER put these in `.shotgun/contracts/` - NO EXECUTABLE CODE:**
69
+ - ❌ **Functions or methods with implementations** (even with `pass` or empty bodies)
70
+ - ❌ **Helper functions** with any logic whatsoever
71
+ - ❌ **Classes with method implementations** (use Protocol classes instead)
72
+ - ❌ **Standalone functions** like `def main(): pass` or `def validate_input(x): ...`
73
+ - ❌ **Code with behavior**: loops, conditionals, data manipulation, computations
74
+ - ❌ **Data constants**: dictionaries, lists, or any runtime values
75
+ - ❌ **`if __name__ == "__main__":` blocks** or any executable code
76
+ - Build/dependency configs (pyproject.toml, package.json, Cargo.toml, requirements.txt)
77
+ - Directory structure files (directory_structure.txt)
78
+ - Configuration templates (.env, config.yaml, example configs)
79
+ - Documentation or markdown files
80
+ - SQL migration files or database dumps
81
+
82
+ **These belong in specification.md instead:**
83
+ - Directory structure (as succinct prose: "src/ contains modules, tests/ has unit tests")
84
+ - Dependencies (as prose: "Requires Rust 1.70+, tokio, serde")
85
+ - Configuration needs (describe: "App needs DB_URL and API_KEY environment variables")
86
+
87
+ **Guidelines for contract files:**
88
+ - Keep each file focused on a single domain (e.g., user_types.ts, payment_models.py)
89
+ - Reference from specification.md: "See contracts/user_types.ts for User and Profile types"
90
+ - Use descriptive filenames: `auth_models.py`, `api_spec.json`, `database_types.rs`
91
+ - Keep files under 500 lines to avoid truncation
92
+ - When contracts grow large, split into focused files
93
+
94
+ **Example workflow:**
95
+ 1. In specification.md: "Authentication system with JWT tokens. See contracts/auth_types.ts for types."
96
+ 2. Create contract file: `write_file("contracts/auth_types.ts", content)` with actual TypeScript interfaces
97
+ 3. Create contract file: `write_file("contracts/auth_api.json", content)` with actual OpenAPI spec
98
+ 4. Coding agents can directly use these contracts to implement features
99
+
100
+ ## HOW TO WRITE CONTRACT FILES
101
+
102
+ **CRITICAL - Always use correct file paths with write_file():**
103
+
104
+ Your working directory is `.shotgun/`, so paths should be relative to that directory.
105
+
106
+ <GOOD_EXAMPLES>
107
+ ✅ `write_file("contracts/user_models.py", content)` - Correct path for Python models
108
+ ✅ `write_file("contracts/auth_types.ts", content)` - Correct path for TypeScript types
109
+ ✅ `write_file("contracts/api_spec.json", content)` - Correct path for OpenAPI spec
110
+ ✅ `write_file("contracts/payment_service.rs", content)` - Correct path for Rust code
111
+ </GOOD_EXAMPLES>
112
+
113
+ <BAD_EXAMPLES>
114
+ ❌ `write_file(".shotgun/contracts/user_models.py", content)` - WRONG! Don't include .shotgun/ prefix
115
+ ❌ `write_file("contracts/directory_structure.txt", content)` - WRONG! No documentation files
116
+ ❌ `write_file("contracts/pyproject.toml", content)` - WRONG! No build configs in contracts/
117
+ ❌ `write_file("contracts/requirements.txt", content)` - WRONG! No dependency lists in contracts/
118
+ ❌ `write_file("contracts/config.yaml", content)` - WRONG! No config templates in contracts/
119
+ </BAD_EXAMPLES>
120
+
121
+ **Path format rule:** Always use `contracts/filename.ext`, never `.shotgun/contracts/filename.ext`
122
+
123
+ **Language-specific examples:**
124
+
125
+ <PYTHON_EXAMPLE>
126
+ # Python Pydantic model contract
127
+ from pydantic import BaseModel, Field
128
+ from typing import Optional
129
+
130
+ class User(BaseModel):
131
+ """User model contract."""
132
+ id: int
133
+ email: str = Field(..., description="User email address")
134
+ username: str
135
+ is_active: bool = True
136
+ role: Optional[str] = None
137
+
138
+ # Save as: write_file("contracts/user_models.py", content)
139
+ </PYTHON_EXAMPLE>
140
+
141
+ <TYPESCRIPT_EXAMPLE>
142
+ // TypeScript interface contract
143
+ interface User {
144
+ id: number;
145
+ email: string;
146
+ username: string;
147
+ isActive: boolean;
148
+ role?: string;
149
+ }
150
+
151
+ interface AuthToken {
152
+ token: string;
153
+ expiresAt: Date;
154
+ userId: number;
155
+ }
156
+
157
+ // Save as: write_file("contracts/auth_types.ts", content)
158
+ </TYPESCRIPT_EXAMPLE>
159
+
160
+ <RUST_EXAMPLE>
161
+ // Rust struct contract
162
+ use serde::{Deserialize, Serialize};
163
+
164
+ #[derive(Debug, Serialize, Deserialize)]
165
+ pub struct User {
166
+ pub id: u64,
167
+ pub email: String,
168
+ pub username: String,
169
+ pub is_active: bool,
170
+ pub role: Option<String>,
171
+ }
172
+
173
+ // Save as: write_file("contracts/user_types.rs", content)
174
+ </RUST_EXAMPLE>
175
+
176
+ <OPENAPI_EXAMPLE>
177
+ {
178
+ "openapi": "3.0.0",
179
+ "info": {
180
+ "title": "User API",
181
+ "version": "1.0.0"
182
+ },
183
+ "paths": {
184
+ "/users": {
185
+ "get": {
186
+ "summary": "List users",
187
+ "responses": {
188
+ "200": {
189
+ "description": "Successful response",
190
+ "content": {
191
+ "application/json": {
192
+ "schema": {
193
+ "type": "array",
194
+ "items": { "$ref": "#/components/schemas/User" }
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+ },
203
+ "components": {
204
+ "schemas": {
205
+ "User": {
206
+ "type": "object",
207
+ "properties": {
208
+ "id": { "type": "integer" },
209
+ "email": { "type": "string" },
210
+ "username": { "type": "string" }
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // Save as: write_file("contracts/user_api.json", content)
218
+ </OPENAPI_EXAMPLE>
219
+
220
+ ## WHAT IS ALLOWED vs WHAT IS FORBIDDEN
221
+
222
+ **✅ ALLOWED - Type Definitions (Shape and Structure):**
223
+
224
+ ```python
225
+ # ✅ GOOD: Pydantic model (type definition)
226
+ from pydantic import BaseModel
227
+
228
+ class User(BaseModel):
229
+ id: int
230
+ email: str
231
+ username: str
232
+
233
+ # ✅ GOOD: Protocol class (interface definition)
234
+ from typing import Protocol
235
+
236
+ class Storage(Protocol):
237
+ def save(self, data: str) -> None: ...
238
+ def load(self) -> str: ...
239
+
240
+ # ✅ GOOD: Type aliases and enums
241
+ from typing import Literal
242
+ from enum import Enum
243
+
244
+ UserRole = Literal["admin", "user", "guest"]
245
+
246
+ class Status(Enum):
247
+ ACTIVE = "active"
248
+ INACTIVE = "inactive"
249
+ ```
250
+
251
+ **❌ FORBIDDEN - Executable Code (Behavior and Logic):**
252
+
253
+ ```python
254
+ # ❌ BAD: Function with pass (executable code)
255
+ def main() -> int:
256
+ pass
257
+
258
+ # ❌ BAD: Function with implementation
259
+ def validate_input(x: str) -> str:
260
+ return x.strip()
261
+
262
+ # ❌ BAD: Class with method implementations
263
+ class HistoryManager:
264
+ def __init__(self):
265
+ pass
266
+
267
+ def add_message(self, msg: str):
268
+ pass
269
+
270
+ # ❌ BAD: Data constants (runtime values)
271
+ SUPPORTED_PROVIDERS = [
272
+ {"name": "openai", "key": "OPENAI_API_KEY"}
273
+ ]
274
+
275
+ # ❌ BAD: Helper functions
276
+ def get_default_config() -> dict:
277
+ return {"model": "gpt-4"}
278
+
279
+ # ❌ BAD: Executable code blocks
280
+ if __name__ == "__main__":
281
+ main()
282
+ ```
283
+
284
+ **Remember**: Contracts define **SHAPES** (types, interfaces, schemas), NOT **BEHAVIOR** (functions, logic, implementations).
285
+
19
286
  ## AI AGENT PIPELINE AWARENESS
20
287
 
21
288
  **CRITICAL**: Your output will be consumed by AI coding agents (Claude Code, Cursor, Windsurf, etc.)
shotgun/tui/app.py CHANGED
@@ -152,5 +152,121 @@ def run(no_update_check: bool = False, continue_session: bool = False) -> None:
152
152
  app.run(inline_no_clear=True)
153
153
 
154
154
 
155
+ def serve(
156
+ host: str = "localhost",
157
+ port: int = 8000,
158
+ public_url: str | None = None,
159
+ no_update_check: bool = False,
160
+ continue_session: bool = False,
161
+ ) -> None:
162
+ """Serve the TUI application as a web application.
163
+
164
+ Args:
165
+ host: Host address for the web server.
166
+ port: Port number for the web server.
167
+ public_url: Public URL if behind a proxy.
168
+ no_update_check: If True, disable automatic update checks.
169
+ continue_session: If True, continue from previous conversation.
170
+ """
171
+ # Clean up any corrupted databases BEFORE starting the TUI
172
+ # This prevents crashes from corrupted databases during initialization
173
+ import asyncio
174
+
175
+ from textual_serve.server import Server
176
+
177
+ from shotgun.codebase.core.manager import CodebaseGraphManager
178
+ from shotgun.utils import get_shotgun_home
179
+
180
+ storage_dir = get_shotgun_home() / "codebases"
181
+ manager = CodebaseGraphManager(storage_dir)
182
+
183
+ try:
184
+ removed = asyncio.run(manager.cleanup_corrupted_databases())
185
+ if removed:
186
+ logger.info(
187
+ f"Cleaned up {len(removed)} corrupted database(s) before TUI startup"
188
+ )
189
+ except Exception as e:
190
+ logger.error(f"Failed to cleanup corrupted databases: {e}")
191
+ # Continue anyway - the TUI can still function
192
+
193
+ # Create a new event loop after asyncio.run() closes the previous one
194
+ # This is needed for the Server.serve() method
195
+ loop = asyncio.new_event_loop()
196
+ asyncio.set_event_loop(loop)
197
+
198
+ # Build the command string based on flags
199
+ command = "shotgun"
200
+ if no_update_check:
201
+ command += " --no-update-check"
202
+ if continue_session:
203
+ command += " --continue"
204
+
205
+ # Create and start the server with hardcoded title and debug=False
206
+ server = Server(
207
+ command=command,
208
+ host=host,
209
+ port=port,
210
+ title="The Shotgun",
211
+ public_url=public_url,
212
+ )
213
+
214
+ # Set up graceful shutdown on SIGTERM/SIGINT
215
+ import signal
216
+ import sys
217
+
218
+ def signal_handler(_signum: int, _frame: Any) -> None:
219
+ """Handle shutdown signals gracefully."""
220
+ from shotgun.posthog_telemetry import shutdown
221
+
222
+ logger.info("Received shutdown signal, cleaning up...")
223
+ # Restore stdout/stderr before shutting down
224
+ sys.stdout = original_stdout
225
+ sys.stderr = original_stderr
226
+ shutdown()
227
+ sys.exit(0)
228
+
229
+ signal.signal(signal.SIGTERM, signal_handler)
230
+ signal.signal(signal.SIGINT, signal_handler)
231
+
232
+ # Suppress the textual-serve banner by redirecting stdout/stderr
233
+ import io
234
+
235
+ # Capture and suppress the banner, but show the actual serving URL
236
+ original_stdout = sys.stdout
237
+ original_stderr = sys.stderr
238
+
239
+ captured_output = io.StringIO()
240
+ sys.stdout = captured_output
241
+ sys.stderr = captured_output
242
+
243
+ try:
244
+ # This will print the banner to our captured output
245
+ import logging
246
+
247
+ # Temporarily set logging to ERROR level to suppress INFO messages
248
+ textual_serve_logger = logging.getLogger("textual_serve")
249
+ original_level = textual_serve_logger.level
250
+ textual_serve_logger.setLevel(logging.ERROR)
251
+
252
+ # Print our own message to the original stdout
253
+ sys.stdout = original_stdout
254
+ sys.stderr = original_stderr
255
+ print(f"Serving Shotgun TUI at http://{host}:{port}")
256
+ print("Press Ctrl+C to quit")
257
+
258
+ # Now suppress output again for the serve call
259
+ sys.stdout = captured_output
260
+ sys.stderr = captured_output
261
+
262
+ server.serve(debug=False)
263
+ finally:
264
+ # Restore original stdout/stderr
265
+ sys.stdout = original_stdout
266
+ sys.stderr = original_stderr
267
+ if "textual_serve_logger" in locals():
268
+ textual_serve_logger.setLevel(original_level)
269
+
270
+
155
271
  if __name__ == "__main__":
156
272
  run()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shotgun-sh
3
- Version: 0.2.7.dev1
3
+ Version: 0.2.8.dev1
4
4
  Summary: AI-powered research, planning, and task management CLI tool
5
5
  Project-URL: Homepage, https://shotgun.sh/
6
6
  Project-URL: Repository, https://github.com/shotgun-sh/shotgun
@@ -34,7 +34,9 @@ Requires-Dist: pydantic-ai>=0.0.14
34
34
  Requires-Dist: rich>=13.0.0
35
35
  Requires-Dist: sentencepiece>=0.2.0
36
36
  Requires-Dist: sentry-sdk[pure-eval]>=2.0.0
37
+ Requires-Dist: tenacity>=8.0.0
37
38
  Requires-Dist: textual-dev>=1.7.0
39
+ Requires-Dist: textual-serve>=0.1.0
38
40
  Requires-Dist: textual>=6.1.0
39
41
  Requires-Dist: tiktoken>=0.7.0
40
42
  Requires-Dist: tree-sitter-go>=0.23.0
@@ -2,14 +2,14 @@ shotgun/__init__.py,sha256=P40K0fnIsb7SKcQrFnXZ4aREjpWchVDhvM1HxI4cyIQ,104
2
2
  shotgun/api_endpoints.py,sha256=TvxuJyMrZLy6KZTrR6lrdkG8OBtb3TJ48qaw3pWitO0,526
3
3
  shotgun/build_constants.py,sha256=RXNxMz46HaB5jucgMVpw8a2yCJqjbhTOh0PddyEVMN8,713
4
4
  shotgun/logging_config.py,sha256=UKenihvgH8OA3W0b8ZFcItYaFJVe9MlsMYlcevyW1HY,7440
5
- shotgun/main.py,sha256=RA3q1xPfqxCu43UmgI2ryZpA-IxPhJb_MJrbLqp9c_g,5140
5
+ shotgun/main.py,sha256=8t-jw4KZAlvUVvoqMlp0rTVCXtJx4herSheI2N8i-8Y,6445
6
6
  shotgun/posthog_telemetry.py,sha256=TOiyBtLg21SttHGWKc4-e-PQgpbq6Uz_4OzlvlxMcZ0,6099
7
7
  shotgun/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  shotgun/sentry_telemetry.py,sha256=VD8es-tREfgtRKhDsEVvqpo0_kM_ab6iVm2lkOEmTlI,2950
9
9
  shotgun/telemetry.py,sha256=C8dM7Feo1OxJMwDvgAMaA2RyRDO2rYPvC_8kLBuRUS8,3683
10
10
  shotgun/agents/__init__.py,sha256=8Jzv1YsDuLyNPFJyckSr_qI4ehTVeDyIMDW4omsfPGc,25
11
- shotgun/agents/agent_manager.py,sha256=Fjm_7fDqmaqWgjAgGiiIdMNnK0ahanLLZ4bxJIbrMlE,32611
12
- shotgun/agents/common.py,sha256=g8QW782XZfZHjAJPC6k2uXqoX4UZufvbGaejzWgya8E,17768
11
+ shotgun/agents/agent_manager.py,sha256=CgG_Cj1P_ZB7wzwvqqqJ5gxOUVKTGy_o0VS4eaB7NqI,39522
12
+ shotgun/agents/common.py,sha256=0F_dwEc72APvbY1b-lqM9VgPYuY1GpfhjSWv5-1pCB0,19032
13
13
  shotgun/agents/conversation_history.py,sha256=c-1PBaG9FXPfSmekWtrD6s6YVT0sd98DdDhzS4a1Hyo,7859
14
14
  shotgun/agents/conversation_manager.py,sha256=X3DWZZIqrv0hS1SUasyoxexnLPBUrZMBg4ZmRAipkBE,4429
15
15
  shotgun/agents/export.py,sha256=7ZNw7781WJ4peLSeoUc7zxKeaZVxFpow2y4nU4dCfbo,2919
@@ -42,7 +42,7 @@ shotgun/agents/history/token_counting/sentencepiece_counter.py,sha256=qj1bT7J5nC
42
42
  shotgun/agents/history/token_counting/tokenizer_cache.py,sha256=Y0V6KMtEwn42M5-zJGAc7YudM8X6m5-j2ekA6YGL5Xk,2868
43
43
  shotgun/agents/history/token_counting/utils.py,sha256=d124IDjtd0IYBYrr3gDJGWxSbdP10Vrc7ZistbUosMg,5002
44
44
  shotgun/agents/tools/__init__.py,sha256=kYppd4f4MoJcfTEPzkY2rqtxL1suXRGa9IRUm1G82GY,717
45
- shotgun/agents/tools/file_management.py,sha256=HYNe_QA4T3_bPzSWBYcFZcnWdj8eb4aQ3GB735-G8Nw,7138
45
+ shotgun/agents/tools/file_management.py,sha256=7qj2yxzOC_40_swuBIcRcFv6MSZM78mNfsV3SCL_sH4,9205
46
46
  shotgun/agents/tools/codebase/__init__.py,sha256=ceAGkK006NeOYaIJBLQsw7Q46sAyCRK9PYDs8feMQVw,661
47
47
  shotgun/agents/tools/codebase/codebase_shell.py,sha256=9b7ZStAVFprdGqp1O23ZgwkToMytlUdp_R4MhvmENhc,8584
48
48
  shotgun/agents/tools/codebase/directory_lister.py,sha256=eX5GKDSmbKggKDvjPpYMa2WPSGPYQAtUEZ4eN01T0t8,4703
@@ -90,7 +90,7 @@ shotgun/prompts/agents/__init__.py,sha256=YRIJMbzpArojNX1BP5gfxxois334z_GQga8T-x
90
90
  shotgun/prompts/agents/export.j2,sha256=DGqVijH1PkpkY0rDauU52u_fMv15frEvOdXAPFZNMM4,17057
91
91
  shotgun/prompts/agents/plan.j2,sha256=mbt505NdqmzmPxXzQYJS_gH5vkiVa2a3Dgz2K-15JZk,6093
92
92
  shotgun/prompts/agents/research.j2,sha256=QFoSSiF_5v7c78RHaiucZEb9mOC_wF54BVKnJEH_DnI,3964
93
- shotgun/prompts/agents/specify.j2,sha256=AP7XrA3KE7GZsCvW4guASxZHBM2mnrMw3irdZ3RUOBs,2808
93
+ shotgun/prompts/agents/specify.j2,sha256=XdB2WehbVmszw9crl6PEHLyLgwvU08MV--ClV3hI4mA,12014
94
94
  shotgun/prompts/agents/tasks.j2,sha256=SMvTQPzRR6eHlW3fcj-7Bl-Lh9HWaiF3uAKv77nMdZw,5956
95
95
  shotgun/prompts/agents/partials/codebase_understanding.j2,sha256=7WH-PVd-TRBFQUdOdKkwwn9hAUaJznFZMAGHhO7IGGU,5633
96
96
  shotgun/prompts/agents/partials/common_agent_system_prompt.j2,sha256=wfjsQGcMTWWGBk9l0pKDnPehG8NrwTHm5FFEqba__LI,2161
@@ -119,7 +119,7 @@ shotgun/shotgun_web/client.py,sha256=n5DDuVfSa6VPZjhSsfSxQlSFOnhgDHyidRnB8Hv9XF4
119
119
  shotgun/shotgun_web/constants.py,sha256=eNvtjlu81bAVQaCwZXOVjSpDopUm9pf34XuZEvuMiko,661
120
120
  shotgun/shotgun_web/models.py,sha256=Ie9VfqKZM2tIJhIjentU9qLoNaMZvnUJaIu-xg9kQsA,1391
121
121
  shotgun/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
122
- shotgun/tui/app.py,sha256=B2tKbXeGhWBIVec1jJHGAuBcP1SMbO_6xol2OaBpw2Y,5374
122
+ shotgun/tui/app.py,sha256=dBoniec5iscVozM7BTDiKAwMWLUvzw2CioLiTqtmoqo,9184
123
123
  shotgun/tui/filtered_codebase_service.py,sha256=lJ8gTMhIveTatmvmGLP299msWWTkVYKwvY_2FhuL2s4,1687
124
124
  shotgun/tui/styles.tcss,sha256=ETyyw1bpMBOqTi5RLcAJUScdPWTvAWEqE9YcT0kVs_E,121
125
125
  shotgun/tui/commands/__init__.py,sha256=8D5lvtpqMW5-fF7Bg3oJtUzU75cKOv6aUaHYYszydU8,2518
@@ -148,8 +148,8 @@ shotgun/utils/env_utils.py,sha256=ulM3BRi9ZhS7uC-zorGeDQm4SHvsyFuuU9BtVPqdrHY,14
148
148
  shotgun/utils/file_system_utils.py,sha256=l-0p1bEHF34OU19MahnRFdClHufThfGAjQ431teAIp0,1004
149
149
  shotgun/utils/source_detection.py,sha256=Co6Q03R3fT771TF3RzB-70stfjNP2S4F_ArZKibwzm8,454
150
150
  shotgun/utils/update_checker.py,sha256=IgzPHRhS1ETH7PnJR_dIx6lxgr1qHpCkMTgzUxvGjhI,7586
151
- shotgun_sh-0.2.7.dev1.dist-info/METADATA,sha256=MEaQlu9HJSSTi9_WuCgVgC8nX5J34O8PzyP8b8mCj_4,4268
152
- shotgun_sh-0.2.7.dev1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
153
- shotgun_sh-0.2.7.dev1.dist-info/entry_points.txt,sha256=asZxLU4QILneq0MWW10saVCZc4VWhZfb0wFZvERnzfA,45
154
- shotgun_sh-0.2.7.dev1.dist-info/licenses/LICENSE,sha256=YebsZl590zCHrF_acCU5pmNt0pnAfD2DmAnevJPB1tY,1065
155
- shotgun_sh-0.2.7.dev1.dist-info/RECORD,,
151
+ shotgun_sh-0.2.8.dev1.dist-info/METADATA,sha256=razgwSFigJNH56uBlRPZ8yxyQjjvThAbMZpHlPI4_JY,4335
152
+ shotgun_sh-0.2.8.dev1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
153
+ shotgun_sh-0.2.8.dev1.dist-info/entry_points.txt,sha256=asZxLU4QILneq0MWW10saVCZc4VWhZfb0wFZvERnzfA,45
154
+ shotgun_sh-0.2.8.dev1.dist-info/licenses/LICENSE,sha256=YebsZl590zCHrF_acCU5pmNt0pnAfD2DmAnevJPB1tY,1065
155
+ shotgun_sh-0.2.8.dev1.dist-info/RECORD,,