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.
- package/CLAUDE.md +120 -0
- package/bin/devforgeai.js +0 -0
- package/package.json +9 -1
- package/src/CLAUDE.md +699 -0
- package/src/claude/hooks/phase-completion-gate.sh +0 -0
- package/src/claude/scripts/README.md +396 -0
- package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
- package/src/claude/scripts/check-hooks-fast.sh +70 -0
- package/src/claude/scripts/devforgeai-validate +6 -0
- package/src/claude/scripts/devforgeai_cli/README.md +531 -0
- package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
- package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
- package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
- package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
- package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
- package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
- package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
- package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
- package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
- package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
- package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
- package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
- package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
- package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
- package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
- package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
- package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
- package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
- package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
- package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
- package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
- package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
- package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
- package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
- package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
- package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
- package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
- package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
- package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
- package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
- package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
- package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
- package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
- package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
- package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
- package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
- package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
- package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
- package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
- package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
- package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
- package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
- package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
- package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
- package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
- package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
- package/src/claude/scripts/install_hooks.sh +186 -0
- package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
- package/src/claude/scripts/migrate-ac-headers.sh +122 -0
- package/src/claude/scripts/plan_file_kb.sh +704 -0
- package/src/claude/scripts/requirements.txt +8 -0
- package/src/claude/scripts/session_catalog.sh +543 -0
- package/src/claude/scripts/setup.py +55 -0
- package/src/claude/scripts/start-devforgeai.sh +16 -0
- package/src/claude/scripts/statusline.sh +27 -0
- package/src/claude/scripts/validate_deferrals.py +344 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
- 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)
|