pdd-cli 0.0.90__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 (144) hide show
  1. pdd/__init__.py +38 -6
  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 +521 -786
  7. pdd/agentic_e2e_fix.py +319 -0
  8. pdd/agentic_e2e_fix_orchestrator.py +426 -0
  9. pdd/agentic_fix.py +118 -3
  10. pdd/agentic_update.py +25 -8
  11. pdd/architecture_sync.py +565 -0
  12. pdd/auth_service.py +210 -0
  13. pdd/auto_deps_main.py +63 -53
  14. pdd/auto_include.py +185 -3
  15. pdd/auto_update.py +125 -47
  16. pdd/bug_main.py +195 -23
  17. pdd/cmd_test_main.py +345 -197
  18. pdd/code_generator.py +4 -2
  19. pdd/code_generator_main.py +118 -32
  20. pdd/commands/__init__.py +6 -0
  21. pdd/commands/analysis.py +87 -29
  22. pdd/commands/auth.py +309 -0
  23. pdd/commands/connect.py +290 -0
  24. pdd/commands/fix.py +136 -113
  25. pdd/commands/maintenance.py +3 -2
  26. pdd/commands/misc.py +8 -0
  27. pdd/commands/modify.py +190 -164
  28. pdd/commands/sessions.py +284 -0
  29. pdd/construct_paths.py +334 -32
  30. pdd/context_generator_main.py +167 -170
  31. pdd/continue_generation.py +6 -3
  32. pdd/core/__init__.py +33 -0
  33. pdd/core/cli.py +27 -3
  34. pdd/core/cloud.py +237 -0
  35. pdd/core/errors.py +4 -0
  36. pdd/core/remote_session.py +61 -0
  37. pdd/crash_main.py +219 -23
  38. pdd/data/llm_model.csv +4 -4
  39. pdd/docs/prompting_guide.md +864 -0
  40. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  41. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  42. pdd/fix_code_loop.py +208 -34
  43. pdd/fix_code_module_errors.py +6 -2
  44. pdd/fix_error_loop.py +291 -38
  45. pdd/fix_main.py +204 -4
  46. pdd/fix_verification_errors_loop.py +235 -26
  47. pdd/fix_verification_main.py +269 -83
  48. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  49. pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
  50. pdd/frontend/dist/index.html +376 -0
  51. pdd/frontend/dist/logo.svg +33 -0
  52. pdd/generate_output_paths.py +46 -5
  53. pdd/generate_test.py +212 -151
  54. pdd/get_comment.py +19 -44
  55. pdd/get_extension.py +8 -9
  56. pdd/get_jwt_token.py +309 -20
  57. pdd/get_language.py +8 -7
  58. pdd/get_run_command.py +7 -5
  59. pdd/insert_includes.py +2 -1
  60. pdd/llm_invoke.py +459 -95
  61. pdd/load_prompt_template.py +15 -34
  62. pdd/path_resolution.py +140 -0
  63. pdd/postprocess.py +4 -1
  64. pdd/preprocess.py +68 -12
  65. pdd/preprocess_main.py +33 -1
  66. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  67. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  68. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  69. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  70. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  71. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  72. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  73. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  74. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  75. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  76. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  77. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  78. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
  79. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  80. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  81. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  82. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  83. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  84. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  85. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  86. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  87. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  88. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  89. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  90. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  91. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  92. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  93. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  94. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  95. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  96. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  97. pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
  98. pdd/prompts/agentic_update_LLM.prompt +192 -338
  99. pdd/prompts/auto_include_LLM.prompt +22 -0
  100. pdd/prompts/change_LLM.prompt +3093 -1
  101. pdd/prompts/detect_change_LLM.prompt +571 -14
  102. pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
  103. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
  104. pdd/prompts/generate_test_LLM.prompt +20 -1
  105. pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
  106. pdd/prompts/insert_includes_LLM.prompt +262 -252
  107. pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
  108. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  109. pdd/remote_session.py +876 -0
  110. pdd/server/__init__.py +52 -0
  111. pdd/server/app.py +335 -0
  112. pdd/server/click_executor.py +587 -0
  113. pdd/server/executor.py +338 -0
  114. pdd/server/jobs.py +661 -0
  115. pdd/server/models.py +241 -0
  116. pdd/server/routes/__init__.py +31 -0
  117. pdd/server/routes/architecture.py +451 -0
  118. pdd/server/routes/auth.py +364 -0
  119. pdd/server/routes/commands.py +929 -0
  120. pdd/server/routes/config.py +42 -0
  121. pdd/server/routes/files.py +603 -0
  122. pdd/server/routes/prompts.py +1322 -0
  123. pdd/server/routes/websocket.py +473 -0
  124. pdd/server/security.py +243 -0
  125. pdd/server/terminal_spawner.py +209 -0
  126. pdd/server/token_counter.py +222 -0
  127. pdd/summarize_directory.py +236 -237
  128. pdd/sync_animation.py +8 -4
  129. pdd/sync_determine_operation.py +329 -47
  130. pdd/sync_main.py +272 -28
  131. pdd/sync_orchestration.py +136 -75
  132. pdd/template_expander.py +161 -0
  133. pdd/templates/architecture/architecture_json.prompt +41 -46
  134. pdd/trace.py +1 -1
  135. pdd/track_cost.py +0 -13
  136. pdd/unfinished_prompt.py +2 -1
  137. pdd/update_main.py +23 -5
  138. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +15 -10
  139. pdd_cli-0.0.118.dist-info/RECORD +227 -0
  140. pdd_cli-0.0.90.dist-info/RECORD +0 -153
  141. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
  142. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
  143. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +0 -0
  144. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
@@ -11,6 +11,7 @@ import sys
11
11
  import json
12
12
  import hashlib
13
13
  import subprocess
14
+ import fnmatch
14
15
  from pathlib import Path
15
16
  from dataclasses import dataclass, field
16
17
  from typing import Dict, List, Optional, Any
@@ -31,10 +32,17 @@ except ImportError:
31
32
  HAS_MSVCRT = False
32
33
 
33
34
  # Import PDD internal modules
34
- from pdd.construct_paths import construct_paths
35
+ from pdd.construct_paths import (
36
+ _detect_context,
37
+ _find_pddrc_file,
38
+ _get_relative_basename,
39
+ _load_pddrc_config,
40
+ construct_paths,
41
+ )
35
42
  from pdd.load_prompt_template import load_prompt_template
36
43
  from pdd.llm_invoke import llm_invoke
37
44
  from pdd.get_language import get_language
45
+ from pdd.template_expander import expand_template
38
46
 
39
47
  # Constants - Use functions for dynamic path resolution
40
48
  def get_pdd_dir():
@@ -55,11 +63,41 @@ META_DIR = get_meta_dir()
55
63
  LOCKS_DIR = get_locks_dir()
56
64
 
57
65
  # Export constants for other modules
58
- __all__ = ['PDD_DIR', 'META_DIR', 'LOCKS_DIR', 'Fingerprint', 'RunReport', 'SyncDecision',
66
+ __all__ = ['PDD_DIR', 'META_DIR', 'LOCKS_DIR', 'Fingerprint', 'RunReport', 'SyncDecision',
59
67
  'sync_determine_operation', 'analyze_conflict_with_llm', 'read_run_report', 'get_pdd_file_paths',
60
68
  '_check_example_success_history']
61
69
 
62
70
 
71
+ def _safe_basename(basename: str) -> str:
72
+ """Sanitize basename for use in metadata filenames.
73
+
74
+ Replaces '/' with '_' to prevent path interpretation when the basename
75
+ contains subdirectory components (e.g., 'core/cloud' -> 'core_cloud').
76
+ """
77
+ return basename.replace('/', '_')
78
+
79
+
80
+ def _extract_name_part(basename: str) -> tuple:
81
+ """Extract directory and name parts from a subdirectory basename.
82
+
83
+ For subdirectory basenames like 'core/cloud', separates the directory
84
+ prefix from the actual name so that filename patterns can be applied
85
+ correctly.
86
+
87
+ Args:
88
+ basename: The full basename, possibly containing subdirectory path.
89
+
90
+ Returns:
91
+ Tuple of (dir_prefix, name_part):
92
+ - 'core/cloud' -> ('core/', 'cloud')
93
+ - 'calculator' -> ('', 'calculator')
94
+ """
95
+ if '/' in basename:
96
+ dir_part, name_part = basename.rsplit('/', 1)
97
+ return dir_part + '/', name_part
98
+ return '', basename
99
+
100
+
63
101
  @dataclass
64
102
  class Fingerprint:
65
103
  """Represents the last known good state of a PDD unit."""
@@ -102,7 +140,7 @@ class SyncLock:
102
140
  def __init__(self, basename: str, language: str):
103
141
  self.basename = basename
104
142
  self.language = language
105
- self.lock_file = get_locks_dir() / f"{basename}_{language}.lock"
143
+ self.lock_file = get_locks_dir() / f"{_safe_basename(basename)}_{language}.lock"
106
144
  self.fd = None
107
145
  self.current_pid = os.getpid()
108
146
 
@@ -212,6 +250,142 @@ def get_extension(language: str) -> str:
212
250
  return extensions.get(language.lower(), language.lower())
213
251
 
214
252
 
253
+ def _resolve_prompts_root(prompts_dir: str) -> Path:
254
+ """Resolve prompts root relative to the .pddrc location when available."""
255
+ prompts_root = Path(prompts_dir)
256
+ pddrc_path = _find_pddrc_file()
257
+ if pddrc_path and not prompts_root.is_absolute():
258
+ prompts_root = pddrc_path.parent / prompts_root
259
+
260
+ parts = prompts_root.parts
261
+ if "prompts" in parts:
262
+ prompt_index = parts.index("prompts")
263
+ prompts_root = Path(*parts[: prompt_index + 1])
264
+
265
+ return prompts_root
266
+
267
+
268
+ def _relative_basename_for_context(basename: str, context_name: Optional[str]) -> str:
269
+ """Strip context-specific prefixes from basename when possible."""
270
+ if not context_name:
271
+ return basename
272
+
273
+ pddrc_path = _find_pddrc_file()
274
+ if not pddrc_path:
275
+ return basename
276
+
277
+ try:
278
+ config = _load_pddrc_config(pddrc_path)
279
+ except ValueError:
280
+ return basename
281
+
282
+ contexts = config.get("contexts", {})
283
+ context_config = contexts.get(context_name, {})
284
+ defaults = context_config.get("defaults", {})
285
+
286
+ matches = []
287
+
288
+ prompts_dir = defaults.get("prompts_dir", "")
289
+ if prompts_dir:
290
+ normalized = prompts_dir.rstrip("/")
291
+ prefix = normalized
292
+ if normalized == "prompts":
293
+ prefix = ""
294
+ elif normalized.startswith("prompts/"):
295
+ prefix = normalized[len("prompts/"):]
296
+
297
+ if prefix and (basename == prefix or basename.startswith(prefix + "/")):
298
+ relative = basename[len(prefix) + 1 :] if basename != prefix else basename.split("/")[-1]
299
+ matches.append((len(prefix), relative))
300
+
301
+ for pattern in context_config.get("paths", []):
302
+ pattern_base = pattern.rstrip("/**").rstrip("/*")
303
+ if fnmatch.fnmatch(basename, pattern) or \
304
+ basename.startswith(pattern_base + "/") or \
305
+ basename == pattern_base:
306
+ relative = _get_relative_basename(basename, pattern)
307
+ matches.append((len(pattern_base), relative))
308
+
309
+ if not matches:
310
+ return basename
311
+
312
+ matches.sort(key=lambda item: item[0], reverse=True)
313
+ return matches[0][1]
314
+
315
+
316
+ def _generate_paths_from_templates(
317
+ basename: str,
318
+ language: str,
319
+ extension: str,
320
+ outputs_config: Dict[str, Any],
321
+ prompt_path: str
322
+ ) -> Dict[str, Path]:
323
+ """
324
+ Generate file paths from template configuration.
325
+
326
+ This function is used by Issue #237 to support extensible output path patterns
327
+ for different project layouts (Next.js, Vue, Python backend, etc.).
328
+
329
+ Args:
330
+ basename: The relative basename (e.g., 'marketplace/AssetCard' or 'credit_helpers')
331
+ language: The full language name (e.g., 'python', 'typescript')
332
+ extension: The file extension (e.g., 'py', 'tsx')
333
+ outputs_config: The 'outputs' section from .pddrc context config
334
+ prompt_path: The prompt file path to use as fallback
335
+
336
+ Returns:
337
+ Dictionary mapping file types ('prompt', 'code', 'test', etc.) to Path objects
338
+ """
339
+ import logging
340
+ logger = logging.getLogger(__name__)
341
+
342
+ # Extract name parts for template context
343
+ parts = basename.split('/')
344
+ name = parts[-1] if parts else basename
345
+ category = '/'.join(parts[:-1]) if len(parts) > 1 else ''
346
+
347
+ # Build dir_prefix (for legacy template compatibility)
348
+ dir_prefix = '/'.join(parts[:-1]) + '/' if len(parts) > 1 else ''
349
+
350
+ # Build template context
351
+ template_context = {
352
+ 'name': name,
353
+ 'category': category,
354
+ 'dir_prefix': dir_prefix,
355
+ 'ext': extension,
356
+ 'language': language,
357
+ }
358
+
359
+ logger.debug(f"Template context: {template_context}")
360
+
361
+ result = {}
362
+
363
+ # Expand templates for each output type
364
+ for output_type, config in outputs_config.items():
365
+ if isinstance(config, dict) and 'path' in config:
366
+ template = config['path']
367
+ expanded = expand_template(template, template_context)
368
+ result[output_type] = Path(expanded)
369
+ logger.debug(f"Template {output_type}: {template} -> {expanded}")
370
+
371
+ # Ensure prompt is always present (fallback to provided prompt_path)
372
+ if 'prompt' not in result:
373
+ result['prompt'] = Path(prompt_path)
374
+
375
+ # Handle test_files for Bug #156 compatibility
376
+ if 'test' in result:
377
+ test_path = result['test']
378
+ test_dir_path = test_path.parent
379
+ test_stem = f"test_{name}"
380
+ if test_dir_path.exists():
381
+ matching_test_files = sorted(test_dir_path.glob(f"{test_stem}*.{extension}"))
382
+ else:
383
+ matching_test_files = [test_path] if test_path.exists() else []
384
+ result['test_files'] = matching_test_files or [test_path]
385
+
386
+ return result
387
+
388
+
215
389
  def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts", context_override: Optional[str] = None) -> Dict[str, Path]:
216
390
  """Returns a dictionary mapping file types to their expected Path objects."""
217
391
  import logging
@@ -220,8 +394,27 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
220
394
 
221
395
  try:
222
396
  # Use construct_paths to get configuration-aware paths
397
+ prompts_root = _resolve_prompts_root(prompts_dir)
223
398
  prompt_filename = f"{basename}_{language}.prompt"
224
- prompt_path = str(Path(prompts_dir) / prompt_filename)
399
+ prompt_path = str(prompts_root / prompt_filename)
400
+ pddrc_path = _find_pddrc_file()
401
+ if pddrc_path:
402
+ try:
403
+ config = _load_pddrc_config(pddrc_path)
404
+ context_name = context_override or _detect_context(Path.cwd(), config, None)
405
+ context_config = config.get('contexts', {}).get(context_name or '', {})
406
+ prompts_dir_config = context_config.get('defaults', {}).get('prompts_dir', '')
407
+ if prompts_dir_config:
408
+ normalized = prompts_dir_config.rstrip('/')
409
+ prefix = normalized
410
+ if normalized == 'prompts':
411
+ prefix = ''
412
+ elif normalized.startswith('prompts/'):
413
+ prefix = normalized[len('prompts/'):]
414
+ if prefix and not (basename == prefix or basename.startswith(prefix + '/')):
415
+ prompt_path = str(prompts_root / prefix / prompt_filename)
416
+ except ValueError:
417
+ pass
225
418
  logger.info(f"Checking prompt_path={prompt_path}, exists={Path(prompt_path).exists()}")
226
419
 
227
420
  # Check if prompt file exists - if not, we still need configuration-aware paths
@@ -237,22 +430,40 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
237
430
  quiet=True,
238
431
  command="sync",
239
432
  command_options={"basename": basename, "language": language},
240
- context_override=context_override
433
+ context_override=context_override,
434
+ path_resolution_mode="cwd"
241
435
  )
242
-
436
+
243
437
  import logging
244
438
  logger = logging.getLogger(__name__)
245
439
  logger.info(f"resolved_config: {resolved_config}")
246
440
  logger.info(f"output_paths: {output_paths}")
247
-
441
+
442
+ # Issue #237: Check for 'outputs' config for template-based path generation
443
+ outputs_config = resolved_config.get('outputs')
444
+ if outputs_config:
445
+ logger.info(f"Using template-based paths from outputs config")
446
+ context_name = context_override or resolved_config.get('_matched_context')
447
+ basename_for_templates = _relative_basename_for_context(basename, context_name)
448
+ result = _generate_paths_from_templates(
449
+ basename=basename_for_templates,
450
+ language=language,
451
+ extension=extension,
452
+ outputs_config=outputs_config,
453
+ prompt_path=prompt_path
454
+ )
455
+ logger.debug(f"get_pdd_file_paths returning (template-based): {result}")
456
+ return result
457
+
458
+ # Legacy path construction (backwards compatibility)
248
459
  # Extract directory configuration from resolved_config
249
460
  # Note: construct_paths sets tests_dir, examples_dir, code_dir keys
250
461
  test_dir = resolved_config.get('tests_dir', 'tests/')
251
462
  example_dir = resolved_config.get('examples_dir', 'examples/')
252
463
  code_dir = resolved_config.get('code_dir', './')
253
-
464
+
254
465
  logger.info(f"Extracted dirs - test: {test_dir}, example: {example_dir}, code: {code_dir}")
255
-
466
+
256
467
  # Ensure directories end with /
257
468
  if test_dir and not test_dir.endswith('/'):
258
469
  test_dir = test_dir + '/'
@@ -260,14 +471,45 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
260
471
  example_dir = example_dir + '/'
261
472
  if code_dir and not code_dir.endswith('/'):
262
473
  code_dir = code_dir + '/'
263
-
264
- # Construct the full paths
265
- test_path = f"{test_dir}test_{basename}.{extension}"
266
- example_path = f"{example_dir}{basename}_example.{extension}"
267
- code_path = f"{code_dir}{basename}.{extension}"
268
-
474
+
475
+ # Extract directory and name parts for subdirectory basename support
476
+ dir_prefix, name_part = _extract_name_part(basename)
477
+
478
+ # Get explicit config paths (these are the SOURCE OF TRUTH when configured)
479
+ # These should be used directly, NOT combined with dir_prefix
480
+ generate_output_path = resolved_config.get('generate_output_path', '')
481
+ example_output_path = resolved_config.get('example_output_path', '')
482
+ test_output_path = resolved_config.get('test_output_path', '')
483
+
484
+ # Construct paths: use explicit config paths directly when configured,
485
+ # otherwise fall back to old behavior with dir_prefix for backwards compat
486
+
487
+ # Code path
488
+ if generate_output_path and generate_output_path.endswith('/'):
489
+ # Explicit complete directory - use directly with just filename
490
+ code_path = f"{generate_output_path}{name_part}.{extension}"
491
+ else:
492
+ # Old behavior - use code_dir + dir_prefix
493
+ code_path = f"{code_dir}{dir_prefix}{name_part}.{extension}"
494
+
495
+ # Example path
496
+ if example_output_path and example_output_path.endswith('/'):
497
+ # Explicit complete directory - use directly with just filename
498
+ example_path = f"{example_output_path}{name_part}_example.{extension}"
499
+ else:
500
+ # Old behavior - use example_dir + dir_prefix
501
+ example_path = f"{example_dir}{dir_prefix}{name_part}_example.{extension}"
502
+
503
+ # Test path
504
+ if test_output_path and test_output_path.endswith('/'):
505
+ # Explicit complete directory - use directly with just filename
506
+ test_path = f"{test_output_path}test_{name_part}.{extension}"
507
+ else:
508
+ # Old behavior - use test_dir + dir_prefix
509
+ test_path = f"{test_dir}{dir_prefix}test_{name_part}.{extension}"
510
+
269
511
  logger.debug(f"Final paths: test={test_path}, example={example_path}, code={code_path}")
270
-
512
+
271
513
  # Convert to Path objects
272
514
  test_path = Path(test_path)
273
515
  example_path = Path(example_path)
@@ -275,7 +517,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
275
517
 
276
518
  # Bug #156: Find all matching test files
277
519
  test_dir_path = test_path.parent
278
- test_stem = f"test_{basename}"
520
+ test_stem = f"test_{name_part}"
279
521
  if test_dir_path.exists():
280
522
  matching_test_files = sorted(test_dir_path.glob(f"{test_stem}*.{extension}"))
281
523
  else:
@@ -296,16 +538,17 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
296
538
  import logging
297
539
  logger = logging.getLogger(__name__)
298
540
  logger.debug(f"construct_paths failed for non-existent prompt, using defaults: {e}")
299
- fallback_test_path = Path(f"test_{basename}.{extension}")
541
+ dir_prefix, name_part = _extract_name_part(basename)
542
+ fallback_test_path = Path(f"{dir_prefix}test_{name_part}.{extension}")
300
543
  # Bug #156: Find matching test files even in fallback
301
544
  if Path('.').exists():
302
- fallback_matching = sorted(Path('.').glob(f"test_{basename}*.{extension}"))
545
+ fallback_matching = sorted(Path('.').glob(f"{dir_prefix}test_{name_part}*.{extension}"))
303
546
  else:
304
547
  fallback_matching = [fallback_test_path] if fallback_test_path.exists() else []
305
548
  return {
306
549
  'prompt': Path(prompt_path),
307
- 'code': Path(f"{basename}.{extension}"),
308
- 'example': Path(f"{basename}_example.{extension}"),
550
+ 'code': Path(f"{dir_prefix}{name_part}.{extension}"),
551
+ 'example': Path(f"{dir_prefix}{name_part}_example.{extension}"),
309
552
  'test': fallback_test_path,
310
553
  'test_files': fallback_matching or [fallback_test_path] # Bug #156
311
554
  }
@@ -321,9 +564,28 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
321
564
  quiet=True,
322
565
  command="sync", # Use sync command to get more tolerant path handling
323
566
  command_options={"basename": basename, "language": language},
324
- context_override=context_override
567
+ context_override=context_override,
568
+ path_resolution_mode="cwd"
325
569
  )
326
-
570
+
571
+ # Issue #237: Check for 'outputs' config for template-based path generation
572
+ # This must be checked even when prompt EXISTS (not just when it doesn't exist)
573
+ outputs_config = resolved_config.get('outputs')
574
+ if outputs_config:
575
+ extension = get_extension(language)
576
+ logger.info(f"Using template-based paths from outputs config (prompt exists)")
577
+ context_name = context_override or resolved_config.get('_matched_context')
578
+ basename_for_templates = _relative_basename_for_context(basename, context_name)
579
+ result = _generate_paths_from_templates(
580
+ basename=basename_for_templates,
581
+ language=language,
582
+ extension=extension,
583
+ outputs_config=outputs_config,
584
+ prompt_path=prompt_path
585
+ )
586
+ logger.debug(f"get_pdd_file_paths returning (template-based, prompt exists): {result}")
587
+ return result
588
+
327
589
  # For sync command, output_file_paths contains the configured paths
328
590
  # Extract the code path from output_file_paths
329
591
  code_path = output_file_paths.get('generate_output_path', '')
@@ -333,10 +595,18 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
333
595
  if not code_path:
334
596
  # Fallback to constructing from basename with configuration
335
597
  extension = get_extension(language)
336
- code_dir = resolved_config.get('generate_output_path', './')
337
- if code_dir and not code_dir.endswith('/'):
338
- code_dir = code_dir + '/'
339
- code_path = f"{code_dir}{basename}.{extension}"
598
+ generate_output_path = resolved_config.get('generate_output_path', '')
599
+ dir_prefix, name_part = _extract_name_part(basename)
600
+
601
+ # Use explicit config path directly when configured (ending with /)
602
+ if generate_output_path and generate_output_path.endswith('/'):
603
+ code_path = f"{generate_output_path}{name_part}.{extension}"
604
+ else:
605
+ # Old behavior - use path + dir_prefix
606
+ code_dir = generate_output_path or './'
607
+ if not code_dir.endswith('/'):
608
+ code_dir = code_dir + '/'
609
+ code_path = f"{code_dir}{dir_prefix}{name_part}.{extension}"
340
610
 
341
611
  # Get configured paths for example and test files using construct_paths
342
612
  # Note: construct_paths requires files to exist, so we need to handle the case
@@ -353,26 +623,31 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
353
623
  try:
354
624
  # Get example path using example command
355
625
  # Pass path_resolution_mode="cwd" so paths resolve relative to CWD (not project root)
626
+ # Pass basename in command_options to preserve subdirectory structure
356
627
  _, _, example_output_paths, _ = construct_paths(
357
628
  input_file_paths={"prompt_file": prompt_path, "code_file": code_path},
358
- force=True, quiet=True, command="example", command_options={},
629
+ force=True, quiet=True, command="example",
630
+ command_options={"basename": basename},
359
631
  context_override=context_override,
360
632
  path_resolution_mode="cwd"
361
633
  )
362
- example_path = Path(example_output_paths.get('output', f"{basename}_example.{get_extension(language)}"))
634
+ dir_prefix, name_part = _extract_name_part(basename)
635
+ example_path = Path(example_output_paths.get('output', f"{dir_prefix}{name_part}_example.{get_extension(language)}"))
363
636
 
364
637
  # Get test path using test command - handle case where test file doesn't exist yet
638
+ # Pass basename in command_options to preserve subdirectory structure
365
639
  try:
366
640
  _, _, test_output_paths, _ = construct_paths(
367
641
  input_file_paths={"prompt_file": prompt_path, "code_file": code_path},
368
- force=True, quiet=True, command="test", command_options={},
642
+ force=True, quiet=True, command="test",
643
+ command_options={"basename": basename},
369
644
  context_override=context_override,
370
645
  path_resolution_mode="cwd"
371
646
  )
372
- test_path = Path(test_output_paths.get('output', f"test_{basename}.{get_extension(language)}"))
647
+ test_path = Path(test_output_paths.get('output', f"{dir_prefix}test_{name_part}.{get_extension(language)}"))
373
648
  except FileNotFoundError:
374
649
  # Test file doesn't exist yet - create default path
375
- test_path = Path(f"test_{basename}.{get_extension(language)}")
650
+ test_path = Path(f"{dir_prefix}test_{name_part}.{get_extension(language)}")
376
651
 
377
652
  finally:
378
653
  # Clean up temporary file if we created it
@@ -391,25 +666,29 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
391
666
  try:
392
667
  # Get configured directories by using construct_paths with just the prompt file
393
668
  # Pass path_resolution_mode="cwd" so paths resolve relative to CWD (not project root)
669
+ # Pass basename in command_options to preserve subdirectory structure
394
670
  _, _, example_output_paths, _ = construct_paths(
395
671
  input_file_paths={"prompt_file": prompt_path},
396
- force=True, quiet=True, command="example", command_options={},
672
+ force=True, quiet=True, command="example",
673
+ command_options={"basename": basename},
397
674
  context_override=context_override,
398
675
  path_resolution_mode="cwd"
399
676
  )
400
- example_path = Path(example_output_paths.get('output', f"{basename}_example.{get_extension(language)}"))
677
+ dir_prefix, name_part = _extract_name_part(basename)
678
+ example_path = Path(example_output_paths.get('output', f"{dir_prefix}{name_part}_example.{get_extension(language)}"))
401
679
 
402
680
  try:
403
681
  _, _, test_output_paths, _ = construct_paths(
404
682
  input_file_paths={"prompt_file": prompt_path},
405
- force=True, quiet=True, command="test", command_options={},
683
+ force=True, quiet=True, command="test",
684
+ command_options={"basename": basename},
406
685
  context_override=context_override,
407
686
  path_resolution_mode="cwd"
408
687
  )
409
- test_path = Path(test_output_paths.get('output', f"test_{basename}.{get_extension(language)}"))
688
+ test_path = Path(test_output_paths.get('output', f"{dir_prefix}test_{name_part}.{get_extension(language)}"))
410
689
  except Exception:
411
690
  # If test path construction fails, use default naming
412
- test_path = Path(f"test_{basename}.{get_extension(language)}")
691
+ test_path = Path(f"{dir_prefix}test_{name_part}.{get_extension(language)}")
413
692
 
414
693
  except Exception:
415
694
  # Final fallback to deriving from code path if all else fails
@@ -429,7 +708,8 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
429
708
 
430
709
  # Bug #156: Find all matching test files
431
710
  test_dir = test_path.parent
432
- test_stem = f"test_{basename}"
711
+ _, name_part_for_glob = _extract_name_part(basename)
712
+ test_stem = f"test_{name_part_for_glob}"
433
713
  extension = get_extension(language)
434
714
  if test_dir.exists():
435
715
  matching_test_files = sorted(test_dir.glob(f"{test_stem}*.{extension}"))
@@ -443,22 +723,24 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts
443
723
  'test': test_path,
444
724
  'test_files': matching_test_files or [test_path] # Bug #156: All matching test files
445
725
  }
446
-
726
+
447
727
  except Exception as e:
448
728
  # Fallback to simple naming if construct_paths fails
449
729
  extension = get_extension(language)
450
- test_path = Path(f"test_{basename}.{extension}")
730
+ dir_prefix, name_part = _extract_name_part(basename)
731
+ test_path = Path(f"{dir_prefix}test_{name_part}.{extension}")
451
732
  # Bug #156: Try to find matching test files even in fallback
452
733
  test_dir = Path('.')
453
- test_stem = f"test_{basename}"
734
+ test_stem = f"{dir_prefix}test_{name_part}"
454
735
  if test_dir.exists():
455
736
  matching_test_files = sorted(test_dir.glob(f"{test_stem}*.{extension}"))
456
737
  else:
457
738
  matching_test_files = [test_path] if test_path.exists() else []
739
+ prompts_root = _resolve_prompts_root(prompts_dir)
458
740
  return {
459
- 'prompt': Path(prompts_dir) / f"{basename}_{language}.prompt",
460
- 'code': Path(f"{basename}.{extension}"),
461
- 'example': Path(f"{basename}_example.{extension}"),
741
+ 'prompt': prompts_root / f"{basename}_{language}.prompt",
742
+ 'code': Path(f"{dir_prefix}{name_part}.{extension}"),
743
+ 'example': Path(f"{dir_prefix}{name_part}_example.{extension}"),
462
744
  'test': test_path,
463
745
  'test_files': matching_test_files or [test_path] # Bug #156: All matching test files
464
746
  }
@@ -483,7 +765,7 @@ def read_fingerprint(basename: str, language: str) -> Optional[Fingerprint]:
483
765
  """Reads and validates the JSON fingerprint file."""
484
766
  meta_dir = get_meta_dir()
485
767
  meta_dir.mkdir(parents=True, exist_ok=True)
486
- fingerprint_file = meta_dir / f"{basename}_{language}.json"
768
+ fingerprint_file = meta_dir / f"{_safe_basename(basename)}_{language}.json"
487
769
 
488
770
  if not fingerprint_file.exists():
489
771
  return None
@@ -510,7 +792,7 @@ def read_run_report(basename: str, language: str) -> Optional[RunReport]:
510
792
  """Reads and validates the JSON run report file."""
511
793
  meta_dir = get_meta_dir()
512
794
  meta_dir.mkdir(parents=True, exist_ok=True)
513
- run_report_file = meta_dir / f"{basename}_{language}_run.json"
795
+ run_report_file = meta_dir / f"{_safe_basename(basename)}_{language}_run.json"
514
796
 
515
797
  if not run_report_file.exists():
516
798
  return None
@@ -888,7 +1170,7 @@ def _check_example_success_history(basename: str, language: str) -> bool:
888
1170
 
889
1171
  # Strategy 2b: Look for historical run reports with exit_code == 0
890
1172
  # Check all run report files in the meta directory that match the pattern
891
- run_report_pattern = f"{basename}_{language}_run"
1173
+ run_report_pattern = f"{_safe_basename(basename)}_{language}_run"
892
1174
  for file in meta_dir.glob(f"{run_report_pattern}*.json"):
893
1175
  try:
894
1176
  with open(file, 'r') as f: