devforgeai 1.0.5 → 1.0.7

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 (133) hide show
  1. package/CLAUDE.md +120 -0
  2. package/bin/devforgeai.js +0 -0
  3. package/package.json +9 -1
  4. package/src/CLAUDE.md +699 -0
  5. package/src/claude/hooks/phase-completion-gate.sh +0 -0
  6. package/src/claude/scripts/README.md +396 -0
  7. package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
  8. package/src/claude/scripts/check-hooks-fast.sh +70 -0
  9. package/src/claude/scripts/devforgeai-validate +6 -0
  10. package/src/claude/scripts/devforgeai_cli/README.md +531 -0
  11. package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
  12. package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
  13. package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
  14. package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
  15. package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
  16. package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
  17. package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
  18. package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
  19. package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
  20. package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
  21. package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
  22. package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
  23. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
  24. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
  25. package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
  26. package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
  27. package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
  28. package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
  29. package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
  30. package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
  31. package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
  32. package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
  33. package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
  34. package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
  35. package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
  36. package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
  37. package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
  38. package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
  39. package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
  40. package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
  41. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
  42. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
  43. package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
  44. package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
  45. package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
  46. package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
  47. package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
  48. package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
  49. package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
  50. package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
  51. package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
  52. package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
  53. package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
  54. package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
  55. package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
  56. package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
  57. package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
  58. package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
  59. package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
  60. package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
  61. package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
  62. package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
  63. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
  64. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
  65. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
  66. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
  67. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
  68. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
  69. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
  70. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
  71. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
  72. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
  73. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
  74. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
  75. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
  76. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
  77. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
  78. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
  79. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
  80. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
  81. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
  82. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
  83. package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
  84. package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
  85. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
  86. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
  87. package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
  88. package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
  89. package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
  90. package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
  91. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
  92. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
  93. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
  94. package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
  95. package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
  96. package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
  97. package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
  98. package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
  99. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
  100. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
  101. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
  102. package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
  103. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
  104. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
  105. package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
  106. package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
  107. package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
  108. package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
  109. package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
  110. package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
  111. package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
  112. package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
  113. package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
  114. package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
  115. package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
  116. package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
  117. package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
  118. package/src/claude/scripts/install_hooks.sh +186 -0
  119. package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
  120. package/src/claude/scripts/migrate-ac-headers.sh +122 -0
  121. package/src/claude/scripts/plan_file_kb.sh +704 -0
  122. package/src/claude/scripts/requirements.txt +8 -0
  123. package/src/claude/scripts/session_catalog.sh +543 -0
  124. package/src/claude/scripts/setup.py +55 -0
  125. package/src/claude/scripts/start-devforgeai.sh +16 -0
  126. package/src/claude/scripts/statusline.sh +27 -0
  127. package/src/claude/scripts/validate_deferrals.py +344 -0
  128. package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
  129. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
  130. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
  131. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
  132. package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
  133. package/src/claude/skills/devforgeai-story-creation/scripts/tests/__pycache__/measure_accuracy.cpython-312.pyc +0 -0
@@ -0,0 +1,163 @@
1
+ """
2
+ Feedback validation utilities
3
+ """
4
+
5
+ import re
6
+ from typing import Tuple, List, Optional
7
+
8
+
9
+ def validate_response_length(response: str, min_length: int = 5, max_length: int = 10000, warn_threshold: int = 2000) -> Tuple[bool, Optional[str]]:
10
+ """
11
+ Validate response length.
12
+
13
+ Args:
14
+ response: Response text
15
+ min_length: Minimum length (default: 5)
16
+ max_length: Maximum length (default: 10000)
17
+ warn_threshold: Threshold for warning (default: 2000)
18
+
19
+ Returns:
20
+ Tuple of (is_valid, warning_message)
21
+ """
22
+ length = len(response)
23
+
24
+ if length < min_length:
25
+ return False, f"Response too short (minimum {min_length} characters)"
26
+
27
+ if length > max_length:
28
+ return False, f"Response too long (maximum {max_length} characters)"
29
+
30
+ if length > warn_threshold:
31
+ return True, f"Response is long ({length} chars). Consider being more concise."
32
+
33
+ return True, None
34
+
35
+
36
+ def detect_spam(text: str) -> bool:
37
+ """
38
+ Detect spam or noise in feedback.
39
+
40
+ Args:
41
+ text: Text to check
42
+
43
+ Returns:
44
+ True if spam detected, False otherwise
45
+ """
46
+ if len(text) == 0:
47
+ return True
48
+
49
+ # Check for character repetition (e.g., "aaaaaaa")
50
+ if len(set(text)) <= 3 and len(text) > 10:
51
+ return True
52
+
53
+ # Check for pattern repetition (e.g., "123412341234")
54
+ if len(text) > 20:
55
+ for pattern_len in range(3, 10):
56
+ pattern = text[:pattern_len]
57
+ if text == pattern * (len(text) // pattern_len):
58
+ return True
59
+
60
+ # Check for low word count
61
+ words = text.split()
62
+ if len(words) < 5 and len(text) > 50:
63
+ return True
64
+
65
+ # Check for non-alphanumeric ratio
66
+ alphanumeric_count = sum(c.isalnum() or c.isspace() for c in text)
67
+ if len(text) > 20 and alphanumeric_count / len(text) < 0.1:
68
+ return True
69
+
70
+ return False
71
+
72
+
73
+ def is_coherent_text(text: str) -> bool:
74
+ """
75
+ Check if text is coherent (not random repetition).
76
+
77
+ Args:
78
+ text: Text to check
79
+
80
+ Returns:
81
+ True if coherent, False otherwise
82
+ """
83
+ if len(text) < 5:
84
+ return True
85
+
86
+ # Check for single character repetition
87
+ if len(set(text)) == 1:
88
+ return False
89
+
90
+ # Check for pattern repetition (check all possible pattern lengths)
91
+ for pattern_len in range(2, min(len(text) // 3 + 1, 10)):
92
+ pattern = text[:pattern_len]
93
+ repetitions = len(text) // pattern_len
94
+ # Check if text consists of repeated pattern
95
+ if repetitions >= 3:
96
+ reconstructed = pattern * repetitions
97
+ # Also check for partial match at end
98
+ if text == reconstructed or text == reconstructed + pattern[:len(text) - len(reconstructed)]:
99
+ return False
100
+
101
+ return True
102
+
103
+
104
+ def check_sensitive_content(feedback: str) -> Tuple[bool, List[str]]:
105
+ """
106
+ Check if feedback contains sensitive content.
107
+
108
+ Args:
109
+ feedback: Feedback text
110
+
111
+ Returns:
112
+ Tuple of (is_sensitive, detected_types)
113
+ """
114
+ detected_types = []
115
+ text_lower = feedback.lower()
116
+
117
+ # Check for API keys or secrets (specific patterns first)
118
+ if re.search(r'sk-[a-zA-Z0-9]{20,}', feedback):
119
+ detected_types.append('secret')
120
+ elif re.search(r'(api[_\s-]?key)\s*[:=]?\s*[\w-]+', text_lower):
121
+ detected_types.append('api_key')
122
+
123
+ # Check for data loss concerns
124
+ if any(phrase in text_lower for phrase in ['data loss', 'deleted', 'lost data', 'production database']):
125
+ detected_types.append('data_loss')
126
+
127
+ # Check for critical issues (but not if it's just the word "exposed" from "api key exposed")
128
+ if any(phrase in text_lower for phrase in ['security breach', 'vulnerability']):
129
+ if 'api_key' not in detected_types and 'secret' not in detected_types:
130
+ detected_types.append('critical_issue')
131
+
132
+ return len(detected_types) > 0, detected_types
133
+
134
+
135
+ def validate_story_id(story_id: str) -> bool:
136
+ """
137
+ Validate story ID format.
138
+
139
+ Args:
140
+ story_id: Story ID to validate
141
+
142
+ Returns:
143
+ True if valid, False otherwise
144
+ """
145
+ pattern = r'^STORY-\d+$'
146
+ return bool(re.match(pattern, story_id))
147
+
148
+
149
+ def validate_workflow_type(workflow_type: str) -> bool:
150
+ """
151
+ Validate workflow type.
152
+
153
+ Args:
154
+ workflow_type: Workflow type to validate
155
+
156
+ Returns:
157
+ True if valid, False otherwise
158
+ """
159
+ valid_types = [
160
+ 'dev', 'qa', 'orchestrate', 'release', 'ideate',
161
+ 'create-story', 'create-epic', 'create-sprint'
162
+ ]
163
+ return workflow_type in valid_types
@@ -0,0 +1,30 @@
1
+ """
2
+ Headless mode answer resolution for CI/CD pipelines (STORY-098).
3
+
4
+ This package provides configuration-based answer resolution for AskUserQuestion
5
+ prompts when running in headless mode (CI=true or -p flag).
6
+ """
7
+
8
+ from .exceptions import HeadlessResolutionError, ConfigurationError
9
+ from .answer_models import (
10
+ AnswerEntry,
11
+ HeadlessModeSettings,
12
+ DefaultSettings,
13
+ HeadlessAnswerConfiguration,
14
+ load_config,
15
+ )
16
+ from .pattern_matcher import PromptPatternMatcher, MatchResult
17
+ from .answer_resolver import HeadlessAnswerResolver
18
+
19
+ __all__ = [
20
+ "HeadlessResolutionError",
21
+ "ConfigurationError",
22
+ "AnswerEntry",
23
+ "HeadlessModeSettings",
24
+ "DefaultSettings",
25
+ "HeadlessAnswerConfiguration",
26
+ "load_config",
27
+ "PromptPatternMatcher",
28
+ "MatchResult",
29
+ "HeadlessAnswerResolver",
30
+ ]
@@ -0,0 +1,206 @@
1
+ """
2
+ Configuration models for headless answer resolution (STORY-098).
3
+
4
+ Follows patterns from feedback/config_manager.py and config_models.py.
5
+ """
6
+ import logging
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Dict, Optional, Any, List
11
+
12
+ import yaml
13
+
14
+ from .exceptions import ConfigurationError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Valid values for defaults.unknown_prompt
19
+ VALID_DEFAULT_STRATEGIES = ["fail", "first_option", "skip"]
20
+
21
+
22
+ @dataclass
23
+ class AnswerEntry:
24
+ """Single answer configuration with pattern and answer."""
25
+
26
+ pattern: str
27
+ answer: str
28
+ description: Optional[str] = None
29
+
30
+ def __post_init__(self):
31
+ if not self.pattern:
32
+ raise ValueError("AnswerEntry requires a pattern")
33
+ if not self.answer:
34
+ raise ValueError("AnswerEntry requires an answer")
35
+
36
+
37
+ @dataclass
38
+ class HeadlessModeSettings:
39
+ """Headless mode configuration settings."""
40
+
41
+ enabled: bool = True
42
+ fail_on_unanswered: bool = True
43
+ log_matches: bool = True
44
+
45
+ def __post_init__(self):
46
+ if not isinstance(self.enabled, bool):
47
+ raise TypeError(f"enabled must be boolean, got {type(self.enabled).__name__}")
48
+ if not isinstance(self.fail_on_unanswered, bool):
49
+ raise TypeError(
50
+ f"fail_on_unanswered must be boolean, got {type(self.fail_on_unanswered).__name__}"
51
+ )
52
+ if not isinstance(self.log_matches, bool):
53
+ raise TypeError(f"log_matches must be boolean, got {type(self.log_matches).__name__}")
54
+
55
+
56
+ @dataclass
57
+ class DefaultSettings:
58
+ """Default behavior settings for unmatched prompts."""
59
+
60
+ unknown_prompt: str = "fail"
61
+
62
+ def __post_init__(self):
63
+ if self.unknown_prompt not in VALID_DEFAULT_STRATEGIES:
64
+ raise ValueError(
65
+ f"unknown_prompt must be one of {VALID_DEFAULT_STRATEGIES}, "
66
+ f"got '{self.unknown_prompt}'"
67
+ )
68
+
69
+
70
+ @dataclass
71
+ class HeadlessAnswerConfiguration:
72
+ """Complete headless answer configuration."""
73
+
74
+ headless_mode: HeadlessModeSettings
75
+ answers: Dict[str, AnswerEntry]
76
+ defaults: DefaultSettings
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: Dict[str, Any]) -> "HeadlessAnswerConfiguration":
80
+ """Create configuration from dictionary."""
81
+ # Parse headless_mode section
82
+ hm_data = data.get("headless_mode", {})
83
+ headless_mode = HeadlessModeSettings(
84
+ enabled=hm_data.get("enabled", True),
85
+ fail_on_unanswered=hm_data.get("fail_on_unanswered", True),
86
+ log_matches=hm_data.get("log_matches", True),
87
+ )
88
+
89
+ # Parse answers section
90
+ answers_data = data.get("answers", {})
91
+ answers = {}
92
+ for key, entry in answers_data.items():
93
+ if isinstance(entry, dict):
94
+ # Validate regex pattern
95
+ pattern = entry.get("pattern", "")
96
+ try:
97
+ re.compile(pattern, re.IGNORECASE)
98
+ except re.error as e:
99
+ logger.warning(f"Invalid regex pattern for '{key}': {pattern} - {e}")
100
+
101
+ answers[key] = AnswerEntry(
102
+ pattern=pattern,
103
+ answer=entry.get("answer", ""),
104
+ description=entry.get("description"),
105
+ )
106
+
107
+ # Parse defaults section
108
+ defaults_data = data.get("defaults", {})
109
+ defaults = DefaultSettings(unknown_prompt=defaults_data.get("unknown_prompt", "fail"))
110
+
111
+ return cls(headless_mode=headless_mode, answers=answers, defaults=defaults)
112
+
113
+
114
+ def _detect_flat_format(data: Dict[str, Any]) -> bool:
115
+ """Detect if config is in legacy flat format."""
116
+ flat_keys = {
117
+ "test_failure_action",
118
+ "deferral_strategy",
119
+ "priority_default",
120
+ "technology_choice",
121
+ "circular_dependency_action",
122
+ "git_conflict_strategy",
123
+ "custom_answers",
124
+ }
125
+ return bool(flat_keys.intersection(data.keys()))
126
+
127
+
128
+ def _migrate_flat_to_nested(data: Dict[str, Any]) -> Dict[str, Any]:
129
+ """Migrate flat format to nested format with deprecation warning."""
130
+ logger.warning(
131
+ "Flat ci-answers.yaml format is deprecated. "
132
+ "Please migrate to nested format with headless_mode, answers, and defaults sections."
133
+ )
134
+
135
+ # Map flat keys to patterns
136
+ key_to_pattern = {
137
+ "test_failure_action": "Tests failed.*How should we proceed",
138
+ "deferral_strategy": "Do you approve this deferral",
139
+ "priority_default": "What is the story priority",
140
+ "technology_choice": "technology.*not specified",
141
+ "circular_dependency_action": "circular dependency",
142
+ "git_conflict_strategy": "Git conflict",
143
+ }
144
+
145
+ answers = {}
146
+ for key, pattern in key_to_pattern.items():
147
+ if key in data:
148
+ answers[key] = {"pattern": pattern, "answer": str(data[key])}
149
+
150
+ # Handle custom_answers if present
151
+ custom = data.get("custom_answers", {})
152
+ if isinstance(custom, dict):
153
+ for pattern, answer in custom.items():
154
+ safe_key = re.sub(r"[^a-zA-Z0-9_]", "_", pattern)[:50]
155
+ answers[f"custom_{safe_key}"] = {"pattern": pattern, "answer": str(answer)}
156
+
157
+ return {
158
+ "headless_mode": {"enabled": True, "fail_on_unanswered": True, "log_matches": True},
159
+ "answers": answers,
160
+ "defaults": {"unknown_prompt": "fail"},
161
+ }
162
+
163
+
164
+ def load_config(config_path: Path) -> HeadlessAnswerConfiguration:
165
+ """
166
+ Load and validate configuration from YAML file.
167
+
168
+ AC#5: Answer Validation on Load
169
+ - Validates YAML syntax
170
+ - Validates required fields
171
+ - Validates field types and enum values
172
+
173
+ Args:
174
+ config_path: Path to ci-answers.yaml file
175
+
176
+ Returns:
177
+ HeadlessAnswerConfiguration object
178
+
179
+ Raises:
180
+ ConfigurationError: If YAML is malformed
181
+ ValueError: If required fields are missing or invalid
182
+ KeyError: If required sections are missing
183
+ """
184
+ if not config_path.exists():
185
+ raise ConfigurationError(f"Configuration file not found: {config_path}")
186
+
187
+ try:
188
+ with open(config_path, "r") as f:
189
+ data = yaml.safe_load(f)
190
+ except yaml.YAMLError as e:
191
+ line = getattr(e, "problem_mark", None)
192
+ line_num = line.line + 1 if line else None
193
+ raise ConfigurationError(f"YAML parsing error: {e}", line_number=line_num)
194
+
195
+ if data is None:
196
+ raise ConfigurationError("Empty configuration file")
197
+
198
+ # Check for flat format and migrate
199
+ if _detect_flat_format(data):
200
+ data = _migrate_flat_to_nested(data)
201
+
202
+ # Validate required sections for nested format
203
+ if "defaults" not in data:
204
+ raise ValueError("Configuration missing required 'defaults' section")
205
+
206
+ return HeadlessAnswerConfiguration.from_dict(data)
@@ -0,0 +1,204 @@
1
+ """
2
+ HeadlessAnswerResolver service (STORY-098).
3
+
4
+ Main entry point for resolving AskUserQuestion prompts in headless mode.
5
+ Follows singleton pattern from feedback/config_manager.py.
6
+
7
+ AC#1: CI Answers Configuration File
8
+ - Loads from devforgeai/config/ci-answers.yaml with fallbacks
9
+ - Supports both nested (preferred) and flat (deprecated) formats
10
+
11
+ AC#3: Fail-on-Unanswered Mode
12
+ - Raises HeadlessResolutionError when fail_on_unanswered=true and no match
13
+
14
+ BR-002: Interactive mode ignores ci-answers.yaml
15
+ - Only resolves when is_headless_mode() returns True
16
+ """
17
+ import logging
18
+ import os
19
+ import threading
20
+ from pathlib import Path
21
+ from typing import List, Optional
22
+
23
+ from .answer_models import HeadlessAnswerConfiguration, load_config
24
+ from .exceptions import HeadlessResolutionError, ConfigurationError
25
+ from .pattern_matcher import PromptPatternMatcher, MatchResult
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class HeadlessAnswerResolver:
31
+ """
32
+ Resolves AskUserQuestion prompts from CI configuration.
33
+
34
+ Singleton pattern for consistent configuration across invocations.
35
+ """
36
+
37
+ _instance: Optional["HeadlessAnswerResolver"] = None
38
+ _lock = threading.Lock()
39
+
40
+ def __init__(
41
+ self,
42
+ config_path: Optional[Path] = None,
43
+ search_paths: Optional[List[Path]] = None,
44
+ ):
45
+ """
46
+ Initialize resolver with configuration path.
47
+
48
+ Args:
49
+ config_path: Explicit path to ci-answers.yaml
50
+ search_paths: List of paths to search for config (in order)
51
+ """
52
+ self._config_path = config_path
53
+ self._search_paths = search_paths or self._default_search_paths()
54
+ self._config: Optional[HeadlessAnswerConfiguration] = None
55
+ self._matcher: Optional[PromptPatternMatcher] = None
56
+ self._loaded = False
57
+
58
+ @staticmethod
59
+ def _default_search_paths() -> List[Path]:
60
+ """Default paths to search for ci-answers.yaml."""
61
+ cwd = Path.cwd()
62
+ return [
63
+ cwd / "devforgeai" / "config" / "ci-answers.yaml",
64
+ cwd / "devforgeai" / "config" / "ci" / "ci-answers.yaml",
65
+ Path.home() / "devforgeai" / "config" / "ci-answers.yaml",
66
+ ]
67
+
68
+ def _find_config_file(self) -> Optional[Path]:
69
+ """Find ci-answers.yaml in search paths."""
70
+ if self._config_path and self._config_path.exists():
71
+ return self._config_path
72
+
73
+ for path in self._search_paths:
74
+ if path.exists():
75
+ logger.debug(f"Found ci-answers.yaml at: {path}")
76
+ return path
77
+
78
+ return None
79
+
80
+ @classmethod
81
+ def get_instance(cls) -> "HeadlessAnswerResolver":
82
+ """
83
+ Get singleton instance.
84
+
85
+ Thread-safe singleton pattern following config_manager.py.
86
+ """
87
+ if cls._instance is None:
88
+ with cls._lock:
89
+ if cls._instance is None:
90
+ cls._instance = cls()
91
+ return cls._instance
92
+
93
+ @classmethod
94
+ def reset_instance(cls) -> None:
95
+ """Reset singleton instance (for testing)."""
96
+ with cls._lock:
97
+ cls._instance = None
98
+
99
+ def is_configured(self) -> bool:
100
+ """Check if configuration file exists."""
101
+ return self._find_config_file() is not None
102
+
103
+ def is_headless_mode(self) -> bool:
104
+ """
105
+ Check if running in headless mode.
106
+
107
+ Detects:
108
+ - CI=true environment variable
109
+ - DEVFORGEAI_HEADLESS=true environment variable
110
+ - Non-interactive terminal (stdin not a tty)
111
+ """
112
+ if os.environ.get("CI") == "true":
113
+ return True
114
+ if os.environ.get("DEVFORGEAI_HEADLESS") == "true":
115
+ return True
116
+ # Check if stdin is a tty (interactive)
117
+ try:
118
+ return not os.isatty(0)
119
+ except Exception:
120
+ return False
121
+
122
+ def load_configuration(self) -> HeadlessAnswerConfiguration:
123
+ """
124
+ Load configuration from file.
125
+
126
+ AC#1: CI Answers Configuration File
127
+ - Reads from configured path or search paths
128
+ - Validates configuration on load (AC#5)
129
+
130
+ Returns:
131
+ HeadlessAnswerConfiguration object
132
+
133
+ Raises:
134
+ ConfigurationError: If config file not found or invalid
135
+ """
136
+ if self._loaded and self._config:
137
+ return self._config
138
+
139
+ config_path = self._find_config_file()
140
+ if config_path is None:
141
+ if self._config_path:
142
+ raise ConfigurationError(f"Configuration file not found: {self._config_path}")
143
+ raise ConfigurationError(
144
+ "No ci-answers.yaml found in search paths: "
145
+ + ", ".join(str(p) for p in self._search_paths)
146
+ )
147
+
148
+ self._config = load_config(config_path)
149
+ self._loaded = True
150
+
151
+ # Initialize pattern matcher
152
+ self._matcher = PromptPatternMatcher(
153
+ patterns={k: {"pattern": v.pattern, "answer": v.answer} for k, v in self._config.answers.items()},
154
+ default_strategy=self._config.defaults.unknown_prompt,
155
+ log_matches=self._config.headless_mode.log_matches,
156
+ )
157
+
158
+ logger.info(f"Loaded headless configuration from: {config_path}")
159
+ return self._config
160
+
161
+ def resolve(
162
+ self, prompt_text: str, options: List[str]
163
+ ) -> Optional[str]:
164
+ """
165
+ Resolve prompt to configured answer.
166
+
167
+ AC#2: Answer Matching Logic
168
+ - Matches prompt text against configured patterns
169
+ - Returns first matching answer
170
+
171
+ AC#3: Fail-on-Unanswered Mode
172
+ - Raises HeadlessResolutionError if no match and fail_on_unanswered=true
173
+
174
+ AC#4: Default Answer Fallback
175
+ - Uses default strategy when no pattern matches
176
+
177
+ Args:
178
+ prompt_text: The AskUserQuestion prompt text
179
+ options: Available answer options
180
+
181
+ Returns:
182
+ Selected answer string, or None if skip strategy
183
+
184
+ Raises:
185
+ HeadlessResolutionError: If no match and fail strategy
186
+ """
187
+ if not self._loaded:
188
+ self.load_configuration()
189
+
190
+ if self._matcher is None:
191
+ raise ConfigurationError("Configuration not loaded")
192
+
193
+ # Check fail_on_unanswered setting
194
+ if self._config and self._config.headless_mode.fail_on_unanswered:
195
+ # Use fail strategy if configured
196
+ result = self._matcher.match(prompt_text)
197
+ if result:
198
+ return result.answer
199
+ # No match - apply fail_on_unanswered
200
+ raise HeadlessResolutionError(prompt_text)
201
+
202
+ # Use default strategy
203
+ result = self._matcher.match_with_fallback(prompt_text, options)
204
+ return result.answer if result else None
@@ -0,0 +1,36 @@
1
+ """
2
+ Exceptions for headless mode answer resolution (STORY-098).
3
+ """
4
+
5
+
6
+ class HeadlessResolutionError(Exception):
7
+ """
8
+ Raised when a prompt cannot be resolved in headless mode.
9
+
10
+ AC#3: Fail-on-Unanswered Mode
11
+ - When fail_on_unanswered: true and no matching answer exists
12
+ - Error message includes prompt text for debugging
13
+ """
14
+
15
+ def __init__(self, prompt_text: str, message: str = None):
16
+ self.prompt_text = prompt_text
17
+ if message is None:
18
+ message = f"Headless mode: No answer configured for prompt '{prompt_text}'"
19
+ super().__init__(message)
20
+
21
+
22
+ class ConfigurationError(Exception):
23
+ """
24
+ Raised when ci-answers.yaml configuration is invalid.
25
+
26
+ AC#5: Answer Validation on Load
27
+ - Invalid YAML syntax
28
+ - Missing required fields
29
+ - Invalid field values
30
+ """
31
+
32
+ def __init__(self, message: str, line_number: int = None):
33
+ self.line_number = line_number
34
+ if line_number:
35
+ message = f"{message} (line {line_number})"
36
+ super().__init__(message)