pdd-cli 0.0.45__py3-none-any.whl → 0.0.118__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.
- pdd/__init__.py +40 -8
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +497 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +526 -0
- pdd/agentic_common.py +598 -0
- pdd/agentic_crash.py +534 -0
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +426 -0
- pdd/agentic_fix.py +1294 -0
- pdd/agentic_langtest.py +162 -0
- pdd/agentic_update.py +387 -0
- pdd/agentic_verify.py +183 -0
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +71 -51
- pdd/auto_include.py +245 -5
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +196 -23
- pdd/bug_to_unit_test.py +2 -0
- pdd/change_main.py +11 -4
- pdd/cli.py +22 -1181
- pdd/cmd_test_main.py +350 -150
- pdd/code_generator.py +60 -18
- pdd/code_generator_main.py +790 -57
- pdd/commands/__init__.py +48 -0
- pdd/commands/analysis.py +306 -0
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +290 -0
- pdd/commands/fix.py +163 -0
- pdd/commands/generate.py +257 -0
- pdd/commands/maintenance.py +175 -0
- pdd/commands/misc.py +87 -0
- pdd/commands/modify.py +256 -0
- pdd/commands/report.py +144 -0
- pdd/commands/sessions.py +284 -0
- pdd/commands/templates.py +215 -0
- pdd/commands/utility.py +110 -0
- pdd/config_resolution.py +58 -0
- pdd/conflicts_main.py +8 -3
- pdd/construct_paths.py +589 -111
- pdd/context_generator.py +10 -2
- pdd/context_generator_main.py +175 -76
- pdd/continue_generation.py +53 -10
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +527 -0
- pdd/core/cloud.py +237 -0
- pdd/core/dump.py +554 -0
- pdd/core/errors.py +67 -0
- pdd/core/remote_session.py +61 -0
- pdd/core/utils.py +90 -0
- pdd/crash_main.py +262 -33
- pdd/data/language_format.csv +71 -63
- pdd/data/llm_model.csv +20 -18
- pdd/detect_change_main.py +5 -4
- pdd/docs/prompting_guide.md +864 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
- pdd/fix_code_loop.py +523 -95
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +491 -92
- pdd/fix_errors_from_unit_tests.py +4 -3
- pdd/fix_main.py +278 -21
- pdd/fix_verification_errors.py +12 -100
- pdd/fix_verification_errors_loop.py +529 -286
- pdd/fix_verification_main.py +294 -89
- pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
- pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
- pdd/frontend/dist/index.html +376 -0
- pdd/frontend/dist/logo.svg +33 -0
- pdd/generate_output_paths.py +139 -15
- pdd/generate_test.py +218 -146
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +318 -22
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +75 -0
- pdd/get_test_command.py +68 -0
- pdd/git_update.py +70 -19
- pdd/incremental_code_generator.py +2 -2
- pdd/insert_includes.py +13 -4
- pdd/llm_invoke.py +1711 -181
- pdd/load_prompt_template.py +19 -12
- pdd/path_resolution.py +140 -0
- pdd/pdd_completion.fish +25 -2
- pdd/pdd_completion.sh +30 -4
- pdd/pdd_completion.zsh +79 -4
- pdd/postprocess.py +14 -4
- pdd/preprocess.py +293 -24
- pdd/preprocess_main.py +41 -6
- pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
- pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
- pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
- pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
- pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
- pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
- pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
- pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
- pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
- pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
- pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
- pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
- pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
- pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
- pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
- pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
- pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
- pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
- pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
- pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
- pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
- pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
- pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
- pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
- pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
- pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
- pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
- pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
- pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
- pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
- pdd/prompts/agentic_update_LLM.prompt +925 -0
- pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
- pdd/prompts/auto_include_LLM.prompt +122 -905
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +686 -27
- pdd/prompts/example_generator_LLM.prompt +22 -1
- pdd/prompts/extract_code_LLM.prompt +5 -1
- pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
- pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
- pdd/prompts/extract_promptline_LLM.prompt +17 -11
- pdd/prompts/find_verification_errors_LLM.prompt +6 -0
- pdd/prompts/fix_code_module_errors_LLM.prompt +12 -2
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +9 -0
- pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
- pdd/prompts/generate_test_LLM.prompt +41 -7
- pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
- pdd/prompts/increase_tests_LLM.prompt +1 -5
- pdd/prompts/insert_includes_LLM.prompt +316 -186
- pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/prompts/trace_LLM.prompt +25 -22
- pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
- pdd/prompts/update_prompt_LLM.prompt +22 -1
- pdd/pytest_output.py +127 -12
- pdd/remote_session.py +876 -0
- pdd/render_mermaid.py +236 -0
- pdd/server/__init__.py +52 -0
- pdd/server/app.py +335 -0
- pdd/server/click_executor.py +587 -0
- pdd/server/executor.py +338 -0
- pdd/server/jobs.py +661 -0
- pdd/server/models.py +241 -0
- pdd/server/routes/__init__.py +31 -0
- pdd/server/routes/architecture.py +451 -0
- pdd/server/routes/auth.py +364 -0
- pdd/server/routes/commands.py +929 -0
- pdd/server/routes/config.py +42 -0
- pdd/server/routes/files.py +603 -0
- pdd/server/routes/prompts.py +1322 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +209 -0
- pdd/server/token_counter.py +222 -0
- pdd/setup_tool.py +648 -0
- pdd/simple_math.py +2 -0
- pdd/split_main.py +3 -2
- pdd/summarize_directory.py +237 -195
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +839 -112
- pdd/sync_main.py +351 -57
- pdd/sync_orchestration.py +1400 -756
- pdd/sync_tui.py +848 -0
- pdd/template_expander.py +161 -0
- pdd/template_registry.py +264 -0
- pdd/templates/architecture/architecture_json.prompt +237 -0
- pdd/templates/generic/generate_prompt.prompt +174 -0
- pdd/trace.py +168 -12
- pdd/trace_main.py +4 -3
- pdd/track_cost.py +140 -63
- pdd/unfinished_prompt.py +51 -4
- pdd/update_main.py +567 -67
- pdd/update_model_costs.py +2 -2
- pdd/update_prompt.py +19 -4
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +29 -11
- pdd_cli-0.0.118.dist-info/RECORD +227 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +1 -1
- pdd_cli-0.0.45.dist-info/RECORD +0 -116
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/agentic_common.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional, Tuple, Dict, Any, Union
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from pdd.llm_invoke import _load_model_data
|
|
19
|
+
except ImportError:
|
|
20
|
+
def _load_model_data(*args, **kwargs):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
# Constants
|
|
24
|
+
AGENT_PROVIDER_PREFERENCE: List[str] = ["anthropic", "google", "openai"]
|
|
25
|
+
DEFAULT_TIMEOUT_SECONDS: float = 240.0
|
|
26
|
+
MIN_VALID_OUTPUT_LENGTH: int = 50
|
|
27
|
+
|
|
28
|
+
# GitHub State Markers
|
|
29
|
+
GITHUB_STATE_MARKER_START = "<!-- PDD_WORKFLOW_STATE:"
|
|
30
|
+
GITHUB_STATE_MARKER_END = "-->"
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Pricing:
|
|
34
|
+
input_per_million: float
|
|
35
|
+
output_per_million: float
|
|
36
|
+
cached_input_multiplier: float = 1.0
|
|
37
|
+
|
|
38
|
+
# Pricing Configuration
|
|
39
|
+
# Gemini: Based on test expectations (Flash: $0.35/$1.05, Cached 50%)
|
|
40
|
+
GEMINI_PRICING_BY_FAMILY = {
|
|
41
|
+
"flash": Pricing(0.35, 1.05, 0.5),
|
|
42
|
+
"pro": Pricing(3.50, 10.50, 0.5), # Placeholder for Pro
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Codex: Based on test expectations ($1.50/$6.00, Cached 25%)
|
|
46
|
+
CODEX_PRICING = Pricing(1.50, 6.00, 0.25)
|
|
47
|
+
|
|
48
|
+
console = Console()
|
|
49
|
+
|
|
50
|
+
def get_available_agents() -> List[str]:
|
|
51
|
+
"""
|
|
52
|
+
Returns list of available provider names based on CLI existence and API key configuration.
|
|
53
|
+
"""
|
|
54
|
+
available = []
|
|
55
|
+
|
|
56
|
+
# 1. Anthropic (Claude)
|
|
57
|
+
# Available if 'claude' CLI exists. API key not strictly required (subscription auth).
|
|
58
|
+
if shutil.which("claude"):
|
|
59
|
+
available.append("anthropic")
|
|
60
|
+
|
|
61
|
+
# 2. Google (Gemini)
|
|
62
|
+
# Available if 'gemini' CLI exists AND (API key is set OR Vertex AI auth is configured)
|
|
63
|
+
has_gemini_cli = shutil.which("gemini") is not None
|
|
64
|
+
has_google_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
|
|
65
|
+
has_vertex_auth = (
|
|
66
|
+
os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") and
|
|
67
|
+
os.environ.get("GOOGLE_GENAI_USE_VERTEXAI") == "true"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if has_gemini_cli and (has_google_key or has_vertex_auth):
|
|
71
|
+
available.append("google")
|
|
72
|
+
|
|
73
|
+
# 3. OpenAI (Codex)
|
|
74
|
+
# Available if 'codex' CLI exists AND OPENAI_API_KEY is set
|
|
75
|
+
if shutil.which("codex") and os.environ.get("OPENAI_API_KEY"):
|
|
76
|
+
available.append("openai")
|
|
77
|
+
|
|
78
|
+
return available
|
|
79
|
+
|
|
80
|
+
def _calculate_gemini_cost(stats: Dict[str, Any]) -> float:
|
|
81
|
+
"""Calculates cost for Gemini based on token stats."""
|
|
82
|
+
total_cost = 0.0
|
|
83
|
+
models = stats.get("models", {})
|
|
84
|
+
|
|
85
|
+
for model_name, data in models.items():
|
|
86
|
+
tokens = data.get("tokens", {})
|
|
87
|
+
prompt = tokens.get("prompt", 0)
|
|
88
|
+
candidates = tokens.get("candidates", 0)
|
|
89
|
+
cached = tokens.get("cached", 0)
|
|
90
|
+
|
|
91
|
+
# Determine pricing family
|
|
92
|
+
family = "flash" if "flash" in model_name.lower() else "pro"
|
|
93
|
+
pricing = GEMINI_PRICING_BY_FAMILY.get(family, GEMINI_PRICING_BY_FAMILY["flash"])
|
|
94
|
+
|
|
95
|
+
# Logic: new_input = max(0, prompt - cached)
|
|
96
|
+
# Assuming 'prompt' is total input tokens
|
|
97
|
+
new_input = max(0, prompt - cached)
|
|
98
|
+
|
|
99
|
+
input_cost = (new_input / 1_000_000) * pricing.input_per_million
|
|
100
|
+
cached_cost = (cached / 1_000_000) * pricing.input_per_million * pricing.cached_input_multiplier
|
|
101
|
+
output_cost = (candidates / 1_000_000) * pricing.output_per_million
|
|
102
|
+
|
|
103
|
+
total_cost += input_cost + cached_cost + output_cost
|
|
104
|
+
|
|
105
|
+
return total_cost
|
|
106
|
+
|
|
107
|
+
def _calculate_codex_cost(usage: Dict[str, Any]) -> float:
|
|
108
|
+
"""Calculates cost for Codex based on usage stats."""
|
|
109
|
+
input_tokens = usage.get("input_tokens", 0)
|
|
110
|
+
output_tokens = usage.get("output_tokens", 0)
|
|
111
|
+
cached_tokens = usage.get("cached_input_tokens", 0)
|
|
112
|
+
|
|
113
|
+
pricing = CODEX_PRICING
|
|
114
|
+
|
|
115
|
+
# Logic: new_input = max(0, input - cached)
|
|
116
|
+
new_input = max(0, input_tokens - cached_tokens)
|
|
117
|
+
|
|
118
|
+
input_cost = (new_input / 1_000_000) * pricing.input_per_million
|
|
119
|
+
cached_cost = (cached_tokens / 1_000_000) * pricing.input_per_million * pricing.cached_input_multiplier
|
|
120
|
+
output_cost = (output_tokens / 1_000_000) * pricing.output_per_million
|
|
121
|
+
|
|
122
|
+
return input_cost + cached_cost + output_cost
|
|
123
|
+
|
|
124
|
+
def run_agentic_task(
|
|
125
|
+
instruction: str,
|
|
126
|
+
cwd: Path,
|
|
127
|
+
*,
|
|
128
|
+
verbose: bool = False,
|
|
129
|
+
quiet: bool = False,
|
|
130
|
+
label: str = "",
|
|
131
|
+
timeout: Optional[float] = None
|
|
132
|
+
) -> Tuple[bool, str, float, str]:
|
|
133
|
+
"""
|
|
134
|
+
Runs an agentic task using available providers in preference order.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
(success, output_text, cost_usd, provider_used)
|
|
138
|
+
"""
|
|
139
|
+
agents = get_available_agents()
|
|
140
|
+
|
|
141
|
+
# Filter agents based on preference order
|
|
142
|
+
candidates = [p for p in AGENT_PROVIDER_PREFERENCE if p in agents]
|
|
143
|
+
|
|
144
|
+
if not candidates:
|
|
145
|
+
msg = "No agent providers are available (check CLI installation and API keys)"
|
|
146
|
+
if not quiet:
|
|
147
|
+
console.print(f"[bold red]{msg}[/bold red]")
|
|
148
|
+
return False, msg, 0.0, ""
|
|
149
|
+
|
|
150
|
+
effective_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT_SECONDS
|
|
151
|
+
|
|
152
|
+
# Create a unique temp file for the prompt
|
|
153
|
+
prompt_filename = f".agentic_prompt_{uuid.uuid4().hex[:8]}.txt"
|
|
154
|
+
prompt_path = cwd / prompt_filename
|
|
155
|
+
|
|
156
|
+
full_instruction = (
|
|
157
|
+
f"{instruction}\n\n"
|
|
158
|
+
f"Read the file {prompt_filename} for instructions. "
|
|
159
|
+
"You have full file access to explore and modify files as needed."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
# Write prompt to file
|
|
164
|
+
with open(prompt_path, "w", encoding="utf-8") as f:
|
|
165
|
+
f.write(full_instruction)
|
|
166
|
+
|
|
167
|
+
for provider in candidates:
|
|
168
|
+
if verbose:
|
|
169
|
+
console.print(f"[dim]Attempting provider: {provider} for task '{label}'[/dim]")
|
|
170
|
+
|
|
171
|
+
success, output, cost = _run_with_provider(
|
|
172
|
+
provider, prompt_path, cwd, effective_timeout, verbose, quiet
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# False Positive Detection
|
|
176
|
+
if success:
|
|
177
|
+
is_false_positive = (cost == 0.0 and len(output.strip()) < MIN_VALID_OUTPUT_LENGTH)
|
|
178
|
+
|
|
179
|
+
if is_false_positive:
|
|
180
|
+
if not quiet:
|
|
181
|
+
console.print(f"[bold red]Provider '{provider}' returned success but appears to be a false positive (Cost: {cost}, Len: {len(output)})[/bold red]")
|
|
182
|
+
# Treat as failure, try next provider
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Check for suspicious files (C, E, T)
|
|
186
|
+
suspicious = []
|
|
187
|
+
for name in ["C", "E", "T"]:
|
|
188
|
+
if (cwd / name).exists():
|
|
189
|
+
suspicious.append(name)
|
|
190
|
+
|
|
191
|
+
if suspicious:
|
|
192
|
+
console.print(f"[bold red]SUSPICIOUS FILES DETECTED: {', '.join(['- ' + s for s in suspicious])}[/bold red]")
|
|
193
|
+
|
|
194
|
+
# Real success
|
|
195
|
+
return True, output, cost, provider
|
|
196
|
+
else:
|
|
197
|
+
if verbose:
|
|
198
|
+
console.print(f"[yellow]Provider {provider} failed: {output}[/yellow]")
|
|
199
|
+
|
|
200
|
+
return False, "All agent providers failed", 0.0, ""
|
|
201
|
+
|
|
202
|
+
finally:
|
|
203
|
+
# Cleanup prompt file
|
|
204
|
+
if prompt_path.exists():
|
|
205
|
+
try:
|
|
206
|
+
os.remove(prompt_path)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def _run_with_provider(
|
|
211
|
+
provider: str,
|
|
212
|
+
prompt_path: Path,
|
|
213
|
+
cwd: Path,
|
|
214
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
215
|
+
verbose: bool = False,
|
|
216
|
+
quiet: bool = False
|
|
217
|
+
) -> Tuple[bool, str, float]:
|
|
218
|
+
"""
|
|
219
|
+
Internal helper to run a specific provider's CLI.
|
|
220
|
+
Returns (success, output_or_error, cost).
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
# Prepare Environment
|
|
224
|
+
env = os.environ.copy()
|
|
225
|
+
env["TERM"] = "dumb"
|
|
226
|
+
env["NO_COLOR"] = "1"
|
|
227
|
+
env["CI"] = "1"
|
|
228
|
+
|
|
229
|
+
cmd: List[str] = []
|
|
230
|
+
|
|
231
|
+
# Construct Command
|
|
232
|
+
if provider == "anthropic":
|
|
233
|
+
# Remove API key to force subscription auth if configured that way
|
|
234
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
235
|
+
# Note: Tests expect NO -p flag for Anthropic, and prompt path as last arg
|
|
236
|
+
cmd = [
|
|
237
|
+
"claude",
|
|
238
|
+
"--dangerously-skip-permissions",
|
|
239
|
+
"--output-format", "json",
|
|
240
|
+
str(prompt_path)
|
|
241
|
+
]
|
|
242
|
+
elif provider == "google":
|
|
243
|
+
cmd = [
|
|
244
|
+
"gemini",
|
|
245
|
+
"-p", str(prompt_path),
|
|
246
|
+
"--yolo",
|
|
247
|
+
"--output-format", "json"
|
|
248
|
+
]
|
|
249
|
+
elif provider == "openai":
|
|
250
|
+
cmd = [
|
|
251
|
+
"codex",
|
|
252
|
+
"exec",
|
|
253
|
+
"--full-auto",
|
|
254
|
+
"--json",
|
|
255
|
+
str(prompt_path)
|
|
256
|
+
]
|
|
257
|
+
else:
|
|
258
|
+
return False, f"Unknown provider {provider}", 0.0
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
result = subprocess.run(
|
|
262
|
+
cmd,
|
|
263
|
+
cwd=cwd,
|
|
264
|
+
env=env,
|
|
265
|
+
capture_output=True,
|
|
266
|
+
text=True,
|
|
267
|
+
timeout=timeout
|
|
268
|
+
)
|
|
269
|
+
except subprocess.TimeoutExpired:
|
|
270
|
+
return False, "Timeout expired", 0.0
|
|
271
|
+
except Exception as e:
|
|
272
|
+
return False, str(e), 0.0
|
|
273
|
+
|
|
274
|
+
if result.returncode != 0:
|
|
275
|
+
return False, f"Exit code {result.returncode}: {result.stderr}", 0.0
|
|
276
|
+
|
|
277
|
+
# Parse JSON Output
|
|
278
|
+
try:
|
|
279
|
+
# Handle JSONL output (Codex sometimes streams)
|
|
280
|
+
output_str = result.stdout.strip()
|
|
281
|
+
data = {}
|
|
282
|
+
|
|
283
|
+
if provider == "openai" and "\n" in output_str:
|
|
284
|
+
# Parse JSONL, look for result type
|
|
285
|
+
lines = output_str.splitlines()
|
|
286
|
+
for line in lines:
|
|
287
|
+
try:
|
|
288
|
+
item = json.loads(line)
|
|
289
|
+
if item.get("type") == "result":
|
|
290
|
+
data = item
|
|
291
|
+
break
|
|
292
|
+
except json.JSONDecodeError:
|
|
293
|
+
continue
|
|
294
|
+
# If no result block found, try parsing last line
|
|
295
|
+
if not data and lines:
|
|
296
|
+
try:
|
|
297
|
+
data = json.loads(lines[-1])
|
|
298
|
+
except:
|
|
299
|
+
pass
|
|
300
|
+
else:
|
|
301
|
+
data = json.loads(output_str)
|
|
302
|
+
|
|
303
|
+
return _parse_provider_json(provider, data)
|
|
304
|
+
except json.JSONDecodeError:
|
|
305
|
+
# Fallback if CLI didn't output valid JSON (sometimes happens on crash)
|
|
306
|
+
return False, f"Invalid JSON output: {result.stdout[:200]}...", 0.0
|
|
307
|
+
|
|
308
|
+
def _parse_provider_json(provider: str, data: Dict[str, Any]) -> Tuple[bool, str, float]:
|
|
309
|
+
"""
|
|
310
|
+
Extracts (success, text_response, cost_usd) from provider JSON.
|
|
311
|
+
"""
|
|
312
|
+
cost = 0.0
|
|
313
|
+
output_text = ""
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
if provider == "anthropic":
|
|
317
|
+
# Anthropic usually provides direct cost
|
|
318
|
+
cost = float(data.get("total_cost_usd", 0.0))
|
|
319
|
+
# Result might be in 'result' or 'response'
|
|
320
|
+
output_text = data.get("result") or data.get("response") or ""
|
|
321
|
+
|
|
322
|
+
elif provider == "google":
|
|
323
|
+
stats = data.get("stats", {})
|
|
324
|
+
cost = _calculate_gemini_cost(stats)
|
|
325
|
+
output_text = data.get("result") or data.get("response") or data.get("output") or ""
|
|
326
|
+
|
|
327
|
+
elif provider == "openai":
|
|
328
|
+
usage = data.get("usage", {})
|
|
329
|
+
cost = _calculate_codex_cost(usage)
|
|
330
|
+
output_text = data.get("result") or data.get("output") or ""
|
|
331
|
+
|
|
332
|
+
return True, str(output_text), cost
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
return False, f"Error parsing {provider} JSON: {e}", 0.0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --- GitHub State Persistence ---
|
|
339
|
+
|
|
340
|
+
def _build_state_marker(workflow_type: str, issue_number: int) -> str:
|
|
341
|
+
return f"{GITHUB_STATE_MARKER_START}{workflow_type}:issue-{issue_number}"
|
|
342
|
+
|
|
343
|
+
def _serialize_state_comment(workflow_type: str, issue_number: int, state: Dict) -> str:
|
|
344
|
+
marker = _build_state_marker(workflow_type, issue_number)
|
|
345
|
+
json_str = json.dumps(state, indent=2)
|
|
346
|
+
return f"{marker}\n{json_str}\n{GITHUB_STATE_MARKER_END}"
|
|
347
|
+
|
|
348
|
+
def _parse_state_from_comment(body: str, workflow_type: str, issue_number: int) -> Optional[Dict]:
|
|
349
|
+
marker = _build_state_marker(workflow_type, issue_number)
|
|
350
|
+
if marker not in body:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
# Extract content between marker and end marker
|
|
355
|
+
start_idx = body.find(marker) + len(marker)
|
|
356
|
+
end_idx = body.find(GITHUB_STATE_MARKER_END, start_idx)
|
|
357
|
+
|
|
358
|
+
if end_idx == -1:
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
json_str = body[start_idx:end_idx].strip()
|
|
362
|
+
return json.loads(json_str)
|
|
363
|
+
except (json.JSONDecodeError, ValueError):
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
def _find_state_comment(
|
|
367
|
+
repo_owner: str,
|
|
368
|
+
repo_name: str,
|
|
369
|
+
issue_number: int,
|
|
370
|
+
workflow_type: str,
|
|
371
|
+
cwd: Path
|
|
372
|
+
) -> Optional[Tuple[int, Dict]]:
|
|
373
|
+
"""
|
|
374
|
+
Returns (comment_id, state_dict) if found, else None.
|
|
375
|
+
"""
|
|
376
|
+
if not shutil.which("gh"):
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
# List comments
|
|
381
|
+
cmd = [
|
|
382
|
+
"gh", "api",
|
|
383
|
+
f"repos/{repo_owner}/{repo_name}/issues/{issue_number}/comments",
|
|
384
|
+
"--method", "GET"
|
|
385
|
+
]
|
|
386
|
+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
387
|
+
if result.returncode != 0:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
comments = json.loads(result.stdout)
|
|
391
|
+
marker = _build_state_marker(workflow_type, issue_number)
|
|
392
|
+
|
|
393
|
+
for comment in comments:
|
|
394
|
+
body = comment.get("body", "")
|
|
395
|
+
if marker in body:
|
|
396
|
+
state = _parse_state_from_comment(body, workflow_type, issue_number)
|
|
397
|
+
if state:
|
|
398
|
+
return comment["id"], state
|
|
399
|
+
|
|
400
|
+
return None
|
|
401
|
+
except Exception:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
def github_save_state(
|
|
405
|
+
repo_owner: str,
|
|
406
|
+
repo_name: str,
|
|
407
|
+
issue_number: int,
|
|
408
|
+
workflow_type: str,
|
|
409
|
+
state: Dict,
|
|
410
|
+
cwd: Path,
|
|
411
|
+
comment_id: Optional[int] = None
|
|
412
|
+
) -> Optional[int]:
|
|
413
|
+
"""
|
|
414
|
+
Creates or updates a GitHub comment with the state. Returns new/existing comment_id.
|
|
415
|
+
"""
|
|
416
|
+
if not shutil.which("gh"):
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
body = _serialize_state_comment(workflow_type, issue_number, state)
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
if comment_id:
|
|
423
|
+
# PATCH existing
|
|
424
|
+
cmd = [
|
|
425
|
+
"gh", "api",
|
|
426
|
+
f"repos/{repo_owner}/{repo_name}/issues/comments/{comment_id}",
|
|
427
|
+
"-X", "PATCH",
|
|
428
|
+
"-f", f"body={body}"
|
|
429
|
+
]
|
|
430
|
+
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
431
|
+
if res.returncode == 0:
|
|
432
|
+
return comment_id
|
|
433
|
+
else:
|
|
434
|
+
# POST new
|
|
435
|
+
cmd = [
|
|
436
|
+
"gh", "api",
|
|
437
|
+
f"repos/{repo_owner}/{repo_name}/issues/{issue_number}/comments",
|
|
438
|
+
"-X", "POST",
|
|
439
|
+
"-f", f"body={body}"
|
|
440
|
+
]
|
|
441
|
+
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
442
|
+
if res.returncode == 0:
|
|
443
|
+
data = json.loads(res.stdout)
|
|
444
|
+
return data.get("id")
|
|
445
|
+
|
|
446
|
+
return None
|
|
447
|
+
except Exception:
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
def github_load_state(
|
|
451
|
+
repo_owner: str,
|
|
452
|
+
repo_name: str,
|
|
453
|
+
issue_number: int,
|
|
454
|
+
workflow_type: str,
|
|
455
|
+
cwd: Path
|
|
456
|
+
) -> Tuple[Optional[Dict], Optional[int]]:
|
|
457
|
+
"""
|
|
458
|
+
Wrapper to find state. Returns (state, comment_id).
|
|
459
|
+
"""
|
|
460
|
+
result = _find_state_comment(repo_owner, repo_name, issue_number, workflow_type, cwd)
|
|
461
|
+
if result:
|
|
462
|
+
return result[1], result[0]
|
|
463
|
+
return None, None
|
|
464
|
+
|
|
465
|
+
def github_clear_state(
|
|
466
|
+
repo_owner: str,
|
|
467
|
+
repo_name: str,
|
|
468
|
+
issue_number: int,
|
|
469
|
+
workflow_type: str,
|
|
470
|
+
cwd: Path
|
|
471
|
+
) -> bool:
|
|
472
|
+
"""
|
|
473
|
+
Deletes the state comment if it exists.
|
|
474
|
+
"""
|
|
475
|
+
result = _find_state_comment(repo_owner, repo_name, issue_number, workflow_type, cwd)
|
|
476
|
+
if not result:
|
|
477
|
+
return True # Already clear
|
|
478
|
+
|
|
479
|
+
comment_id = result[0]
|
|
480
|
+
try:
|
|
481
|
+
cmd = [
|
|
482
|
+
"gh", "api",
|
|
483
|
+
f"repos/{repo_owner}/{repo_name}/issues/comments/{comment_id}",
|
|
484
|
+
"-X", "DELETE"
|
|
485
|
+
]
|
|
486
|
+
subprocess.run(cmd, cwd=cwd, capture_output=True)
|
|
487
|
+
return True
|
|
488
|
+
except Exception:
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
def _should_use_github_state(use_github_state: bool) -> bool:
|
|
492
|
+
if not use_github_state:
|
|
493
|
+
return False
|
|
494
|
+
if os.environ.get("PDD_NO_GITHUB_STATE") == "1":
|
|
495
|
+
return False
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
# --- High Level State Wrappers ---
|
|
499
|
+
|
|
500
|
+
def load_workflow_state(
|
|
501
|
+
cwd: Path,
|
|
502
|
+
issue_number: int,
|
|
503
|
+
workflow_type: str,
|
|
504
|
+
state_dir: Path,
|
|
505
|
+
repo_owner: str,
|
|
506
|
+
repo_name: str,
|
|
507
|
+
use_github_state: bool = True
|
|
508
|
+
) -> Tuple[Optional[Dict], Optional[int]]:
|
|
509
|
+
"""
|
|
510
|
+
Loads state from GitHub (priority) or local file.
|
|
511
|
+
Returns (state_dict, github_comment_id).
|
|
512
|
+
"""
|
|
513
|
+
local_file = state_dir / f"{workflow_type}_state_{issue_number}.json"
|
|
514
|
+
|
|
515
|
+
# Try GitHub first
|
|
516
|
+
if _should_use_github_state(use_github_state):
|
|
517
|
+
gh_state, gh_id = github_load_state(repo_owner, repo_name, issue_number, workflow_type, cwd)
|
|
518
|
+
if gh_state:
|
|
519
|
+
# Cache locally
|
|
520
|
+
try:
|
|
521
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
522
|
+
with open(local_file, "w") as f:
|
|
523
|
+
json.dump(gh_state, f, indent=2)
|
|
524
|
+
except Exception:
|
|
525
|
+
pass # Ignore local cache errors
|
|
526
|
+
return gh_state, gh_id
|
|
527
|
+
|
|
528
|
+
# Fallback to local
|
|
529
|
+
if local_file.exists():
|
|
530
|
+
try:
|
|
531
|
+
with open(local_file, "r") as f:
|
|
532
|
+
return json.load(f), None
|
|
533
|
+
except Exception:
|
|
534
|
+
pass
|
|
535
|
+
|
|
536
|
+
return None, None
|
|
537
|
+
|
|
538
|
+
def save_workflow_state(
|
|
539
|
+
cwd: Path,
|
|
540
|
+
issue_number: int,
|
|
541
|
+
workflow_type: str,
|
|
542
|
+
state: Dict,
|
|
543
|
+
state_dir: Path,
|
|
544
|
+
repo_owner: str,
|
|
545
|
+
repo_name: str,
|
|
546
|
+
use_github_state: bool = True,
|
|
547
|
+
github_comment_id: Optional[int] = None
|
|
548
|
+
) -> Optional[int]:
|
|
549
|
+
"""
|
|
550
|
+
Saves state to local file and GitHub.
|
|
551
|
+
Returns updated github_comment_id.
|
|
552
|
+
"""
|
|
553
|
+
local_file = state_dir / f"{workflow_type}_state_{issue_number}.json"
|
|
554
|
+
|
|
555
|
+
# 1. Save Local
|
|
556
|
+
try:
|
|
557
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
with open(local_file, "w") as f:
|
|
559
|
+
json.dump(state, f, indent=2)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
console.print(f"[yellow]Warning: Failed to save local state: {e}[/yellow]")
|
|
562
|
+
|
|
563
|
+
# 2. Save GitHub
|
|
564
|
+
if _should_use_github_state(use_github_state):
|
|
565
|
+
new_id = github_save_state(
|
|
566
|
+
repo_owner, repo_name, issue_number, workflow_type, state, cwd, github_comment_id
|
|
567
|
+
)
|
|
568
|
+
if new_id:
|
|
569
|
+
return new_id
|
|
570
|
+
else:
|
|
571
|
+
console.print("[dim]Warning: Failed to sync state to GitHub[/dim]")
|
|
572
|
+
|
|
573
|
+
return github_comment_id
|
|
574
|
+
|
|
575
|
+
def clear_workflow_state(
|
|
576
|
+
cwd: Path,
|
|
577
|
+
issue_number: int,
|
|
578
|
+
workflow_type: str,
|
|
579
|
+
state_dir: Path,
|
|
580
|
+
repo_owner: str,
|
|
581
|
+
repo_name: str,
|
|
582
|
+
use_github_state: bool = True
|
|
583
|
+
) -> None:
|
|
584
|
+
"""
|
|
585
|
+
Clears local and GitHub state.
|
|
586
|
+
"""
|
|
587
|
+
local_file = state_dir / f"{workflow_type}_state_{issue_number}.json"
|
|
588
|
+
|
|
589
|
+
# Clear Local
|
|
590
|
+
if local_file.exists():
|
|
591
|
+
try:
|
|
592
|
+
os.remove(local_file)
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
|
|
596
|
+
# Clear GitHub
|
|
597
|
+
if _should_use_github_state(use_github_state):
|
|
598
|
+
github_clear_state(repo_owner, repo_name, issue_number, workflow_type, cwd)
|