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.
Files changed (195) hide show
  1. pdd/__init__.py +40 -8
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +497 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +526 -0
  6. pdd/agentic_common.py +598 -0
  7. pdd/agentic_crash.py +534 -0
  8. pdd/agentic_e2e_fix.py +319 -0
  9. pdd/agentic_e2e_fix_orchestrator.py +426 -0
  10. pdd/agentic_fix.py +1294 -0
  11. pdd/agentic_langtest.py +162 -0
  12. pdd/agentic_update.py +387 -0
  13. pdd/agentic_verify.py +183 -0
  14. pdd/architecture_sync.py +565 -0
  15. pdd/auth_service.py +210 -0
  16. pdd/auto_deps_main.py +71 -51
  17. pdd/auto_include.py +245 -5
  18. pdd/auto_update.py +125 -47
  19. pdd/bug_main.py +196 -23
  20. pdd/bug_to_unit_test.py +2 -0
  21. pdd/change_main.py +11 -4
  22. pdd/cli.py +22 -1181
  23. pdd/cmd_test_main.py +350 -150
  24. pdd/code_generator.py +60 -18
  25. pdd/code_generator_main.py +790 -57
  26. pdd/commands/__init__.py +48 -0
  27. pdd/commands/analysis.py +306 -0
  28. pdd/commands/auth.py +309 -0
  29. pdd/commands/connect.py +290 -0
  30. pdd/commands/fix.py +163 -0
  31. pdd/commands/generate.py +257 -0
  32. pdd/commands/maintenance.py +175 -0
  33. pdd/commands/misc.py +87 -0
  34. pdd/commands/modify.py +256 -0
  35. pdd/commands/report.py +144 -0
  36. pdd/commands/sessions.py +284 -0
  37. pdd/commands/templates.py +215 -0
  38. pdd/commands/utility.py +110 -0
  39. pdd/config_resolution.py +58 -0
  40. pdd/conflicts_main.py +8 -3
  41. pdd/construct_paths.py +589 -111
  42. pdd/context_generator.py +10 -2
  43. pdd/context_generator_main.py +175 -76
  44. pdd/continue_generation.py +53 -10
  45. pdd/core/__init__.py +33 -0
  46. pdd/core/cli.py +527 -0
  47. pdd/core/cloud.py +237 -0
  48. pdd/core/dump.py +554 -0
  49. pdd/core/errors.py +67 -0
  50. pdd/core/remote_session.py +61 -0
  51. pdd/core/utils.py +90 -0
  52. pdd/crash_main.py +262 -33
  53. pdd/data/language_format.csv +71 -63
  54. pdd/data/llm_model.csv +20 -18
  55. pdd/detect_change_main.py +5 -4
  56. pdd/docs/prompting_guide.md +864 -0
  57. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  58. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  59. pdd/fix_code_loop.py +523 -95
  60. pdd/fix_code_module_errors.py +6 -2
  61. pdd/fix_error_loop.py +491 -92
  62. pdd/fix_errors_from_unit_tests.py +4 -3
  63. pdd/fix_main.py +278 -21
  64. pdd/fix_verification_errors.py +12 -100
  65. pdd/fix_verification_errors_loop.py +529 -286
  66. pdd/fix_verification_main.py +294 -89
  67. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  68. pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
  69. pdd/frontend/dist/index.html +376 -0
  70. pdd/frontend/dist/logo.svg +33 -0
  71. pdd/generate_output_paths.py +139 -15
  72. pdd/generate_test.py +218 -146
  73. pdd/get_comment.py +19 -44
  74. pdd/get_extension.py +8 -9
  75. pdd/get_jwt_token.py +318 -22
  76. pdd/get_language.py +8 -7
  77. pdd/get_run_command.py +75 -0
  78. pdd/get_test_command.py +68 -0
  79. pdd/git_update.py +70 -19
  80. pdd/incremental_code_generator.py +2 -2
  81. pdd/insert_includes.py +13 -4
  82. pdd/llm_invoke.py +1711 -181
  83. pdd/load_prompt_template.py +19 -12
  84. pdd/path_resolution.py +140 -0
  85. pdd/pdd_completion.fish +25 -2
  86. pdd/pdd_completion.sh +30 -4
  87. pdd/pdd_completion.zsh +79 -4
  88. pdd/postprocess.py +14 -4
  89. pdd/preprocess.py +293 -24
  90. pdd/preprocess_main.py +41 -6
  91. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  92. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  93. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  94. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  95. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  96. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  97. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  98. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  99. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  100. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  101. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  102. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  103. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
  104. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  105. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  106. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  107. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  108. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  109. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  110. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  111. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  112. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  113. pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
  114. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  115. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  116. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  117. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  118. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  119. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  120. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  121. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  122. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  123. pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
  124. pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
  125. pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
  126. pdd/prompts/agentic_update_LLM.prompt +925 -0
  127. pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
  128. pdd/prompts/auto_include_LLM.prompt +122 -905
  129. pdd/prompts/change_LLM.prompt +3093 -1
  130. pdd/prompts/detect_change_LLM.prompt +686 -27
  131. pdd/prompts/example_generator_LLM.prompt +22 -1
  132. pdd/prompts/extract_code_LLM.prompt +5 -1
  133. pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
  134. pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
  135. pdd/prompts/extract_promptline_LLM.prompt +17 -11
  136. pdd/prompts/find_verification_errors_LLM.prompt +6 -0
  137. pdd/prompts/fix_code_module_errors_LLM.prompt +12 -2
  138. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +9 -0
  139. pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
  140. pdd/prompts/generate_test_LLM.prompt +41 -7
  141. pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
  142. pdd/prompts/increase_tests_LLM.prompt +1 -5
  143. pdd/prompts/insert_includes_LLM.prompt +316 -186
  144. pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
  145. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  146. pdd/prompts/trace_LLM.prompt +25 -22
  147. pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
  148. pdd/prompts/update_prompt_LLM.prompt +22 -1
  149. pdd/pytest_output.py +127 -12
  150. pdd/remote_session.py +876 -0
  151. pdd/render_mermaid.py +236 -0
  152. pdd/server/__init__.py +52 -0
  153. pdd/server/app.py +335 -0
  154. pdd/server/click_executor.py +587 -0
  155. pdd/server/executor.py +338 -0
  156. pdd/server/jobs.py +661 -0
  157. pdd/server/models.py +241 -0
  158. pdd/server/routes/__init__.py +31 -0
  159. pdd/server/routes/architecture.py +451 -0
  160. pdd/server/routes/auth.py +364 -0
  161. pdd/server/routes/commands.py +929 -0
  162. pdd/server/routes/config.py +42 -0
  163. pdd/server/routes/files.py +603 -0
  164. pdd/server/routes/prompts.py +1322 -0
  165. pdd/server/routes/websocket.py +473 -0
  166. pdd/server/security.py +243 -0
  167. pdd/server/terminal_spawner.py +209 -0
  168. pdd/server/token_counter.py +222 -0
  169. pdd/setup_tool.py +648 -0
  170. pdd/simple_math.py +2 -0
  171. pdd/split_main.py +3 -2
  172. pdd/summarize_directory.py +237 -195
  173. pdd/sync_animation.py +8 -4
  174. pdd/sync_determine_operation.py +839 -112
  175. pdd/sync_main.py +351 -57
  176. pdd/sync_orchestration.py +1400 -756
  177. pdd/sync_tui.py +848 -0
  178. pdd/template_expander.py +161 -0
  179. pdd/template_registry.py +264 -0
  180. pdd/templates/architecture/architecture_json.prompt +237 -0
  181. pdd/templates/generic/generate_prompt.prompt +174 -0
  182. pdd/trace.py +168 -12
  183. pdd/trace_main.py +4 -3
  184. pdd/track_cost.py +140 -63
  185. pdd/unfinished_prompt.py +51 -4
  186. pdd/update_main.py +567 -67
  187. pdd/update_model_costs.py +2 -2
  188. pdd/update_prompt.py +19 -4
  189. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +29 -11
  190. pdd_cli-0.0.118.dist-info/RECORD +227 -0
  191. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +1 -1
  192. pdd_cli-0.0.45.dist-info/RECORD +0 -116
  193. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
  194. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
  195. {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)