ref-agents 1.0.0__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.
- ref_agents/__init__.py +9 -0
- ref_agents/api_keys.json.example +8 -0
- ref_agents/auth.py +129 -0
- ref_agents/codemap/..md +62 -0
- ref_agents/codemap/CODE_MAP.md +37 -0
- ref_agents/codemap/core.md +43 -0
- ref_agents/codemap/models.md +43 -0
- ref_agents/codemap/prompts.md +40 -0
- ref_agents/codemap/security.md +45 -0
- ref_agents/codemap/tools.md +94 -0
- ref_agents/codemap/tools_browser.md +44 -0
- ref_agents/codemap/utils.md +42 -0
- ref_agents/codemap/workflow.md +42 -0
- ref_agents/config/ai_patterns.yaml +101 -0
- ref_agents/config/frameworks/angular.yaml +104 -0
- ref_agents/config/frameworks/aspnet.yaml +84 -0
- ref_agents/config/frameworks/ef_core.yaml +81 -0
- ref_agents/config/frameworks/react.yaml +111 -0
- ref_agents/config/frameworks/spring_boot.yaml +117 -0
- ref_agents/config/languages/csharp.yaml +153 -0
- ref_agents/config/languages/java.yaml +188 -0
- ref_agents/config/languages/javascript.yaml +172 -0
- ref_agents/config/languages/python.yaml +153 -0
- ref_agents/config/languages/typescript.yaml +193 -0
- ref_agents/constants.py +553 -0
- ref_agents/core/__init__.py +15 -0
- ref_agents/core/config_loader.py +160 -0
- ref_agents/core/config_models.py +167 -0
- ref_agents/core/config_parsing.py +84 -0
- ref_agents/core/language_detector.py +388 -0
- ref_agents/core/validation_models.py +66 -0
- ref_agents/core/validation_primitives.py +176 -0
- ref_agents/errors.py +34 -0
- ref_agents/license_client.py +247 -0
- ref_agents/models/__init__.py +22 -0
- ref_agents/models/gherkin.py +45 -0
- ref_agents/models/hierarchy.py +80 -0
- ref_agents/models/invest.py +59 -0
- ref_agents/models/version.py +49 -0
- ref_agents/prompts/__init__.py +9 -0
- ref_agents/prompts/start_agent.py +772 -0
- ref_agents/rules/architecture/backend_patterns.md +43 -0
- ref_agents/rules/architecture/diagramming.md +100 -0
- ref_agents/rules/architecture/frontend_patterns.md +40 -0
- ref_agents/rules/architecture/impact_analysis.md +129 -0
- ref_agents/rules/architecture/migration_strategy.md +208 -0
- ref_agents/rules/architecture/regression_protocol.md +77 -0
- ref_agents/rules/architecture/system_design.md +97 -0
- ref_agents/rules/common/codemap_standard.md +97 -0
- ref_agents/rules/common/core_protocol.md +59 -0
- ref_agents/rules/common/prompt_engineering.md +294 -0
- ref_agents/rules/development/debugging.md +32 -0
- ref_agents/rules/development/implementation.md +205 -0
- ref_agents/rules/operations/completion.md +119 -0
- ref_agents/rules/operations/cutover_protocol.md +218 -0
- ref_agents/rules/operations/discovery.md +179 -0
- ref_agents/rules/operations/fix_workflow.md +87 -0
- ref_agents/rules/operations/forensics.md +278 -0
- ref_agents/rules/operations/platform.md +263 -0
- ref_agents/rules/operations/synchronous_flow.md +25 -0
- ref_agents/rules/product/ac_validation.md +25 -0
- ref_agents/rules/product/brainstorming.md +27 -0
- ref_agents/rules/product/ref_flow.md +101 -0
- ref_agents/rules/product/requirements_std.md +114 -0
- ref_agents/rules/product/spec_writing.md +235 -0
- ref_agents/rules/product/strategy.md +96 -0
- ref_agents/rules/quality/documentation_standards.md +46 -0
- ref_agents/rules/quality/parity_testing.md +234 -0
- ref_agents/rules/quality/project_documentation.md +56 -0
- ref_agents/rules/quality/qa_lead.md +111 -0
- ref_agents/rules/quality/test_design.md +146 -0
- ref_agents/rules/quality/testing_standards.md +293 -0
- ref_agents/rules/review/pr_review.md +116 -0
- ref_agents/rules/security/security_audit.md +83 -0
- ref_agents/security/__init__.py +22 -0
- ref_agents/security/dependency_audit.py +188 -0
- ref_agents/security/file_audit.py +208 -0
- ref_agents/security/network_scan.py +179 -0
- ref_agents/security/report_generator.py +313 -0
- ref_agents/security/secret_scan.py +252 -0
- ref_agents/security/url_scan.py +240 -0
- ref_agents/security_scan.py +236 -0
- ref_agents/server.py +1586 -0
- ref_agents/session.py +100 -0
- ref_agents/tool_names.py +55 -0
- ref_agents/tools/__init__.py +8 -0
- ref_agents/tools/agents_generator.py +315 -0
- ref_agents/tools/ai_pattern_detector.py +815 -0
- ref_agents/tools/brownfield_populator.py +529 -0
- ref_agents/tools/browser/__init__.py +50 -0
- ref_agents/tools/browser/evidence_verifier.py +302 -0
- ref_agents/tools/browser/execution_logger.py +249 -0
- ref_agents/tools/browser/playwright_mcp_client.py +259 -0
- ref_agents/tools/browser/screenshot_utils.py +184 -0
- ref_agents/tools/browser/test_executor.py +537 -0
- ref_agents/tools/code_quality_scanner.py +629 -0
- ref_agents/tools/codemap/..md +93 -0
- ref_agents/tools/codemap/CODE_MAP.md +30 -0
- ref_agents/tools/codemap/browser.md +44 -0
- ref_agents/tools/codemap.py +403 -0
- ref_agents/tools/codemap_freshness.py +234 -0
- ref_agents/tools/comment_smell_scanner.py +346 -0
- ref_agents/tools/complexity.py +436 -0
- ref_agents/tools/complexity_ast.py +333 -0
- ref_agents/tools/compliance.py +246 -0
- ref_agents/tools/compliance_remediation.py +846 -0
- ref_agents/tools/context_graph.py +839 -0
- ref_agents/tools/context_manager.py +550 -0
- ref_agents/tools/context_tools.py +121 -0
- ref_agents/tools/cross_repo_linker.py +393 -0
- ref_agents/tools/dead_code_scanner.py +637 -0
- ref_agents/tools/debt_scanner.py +1092 -0
- ref_agents/tools/dependency_graph.py +272 -0
- ref_agents/tools/discovery_audit.py +372 -0
- ref_agents/tools/docs_scanner.py +600 -0
- ref_agents/tools/evaluate_gate.py +119 -0
- ref_agents/tools/external_detector.py +524 -0
- ref_agents/tools/features_generator.py +282 -0
- ref_agents/tools/flow_gap_detector.py +373 -0
- ref_agents/tools/flow_mapper.py +327 -0
- ref_agents/tools/full_suite_runner.py +740 -0
- ref_agents/tools/gherkin_parser.py +227 -0
- ref_agents/tools/guard_tools.py +139 -0
- ref_agents/tools/handoff_tools.py +282 -0
- ref_agents/tools/health_scanner.py +1211 -0
- ref_agents/tools/hierarchy_manager.py +289 -0
- ref_agents/tools/invest_scorer.py +249 -0
- ref_agents/tools/jira_confluence_export.py +306 -0
- ref_agents/tools/json_output.py +76 -0
- ref_agents/tools/migration_mapper.py +946 -0
- ref_agents/tools/migration_readiness_scanner.py +209 -0
- ref_agents/tools/pattern_learner.py +522 -0
- ref_agents/tools/report_utils.py +155 -0
- ref_agents/tools/requirements_serializer.py +225 -0
- ref_agents/tools/security_audit_tool.py +106 -0
- ref_agents/tools/sequencing_engine.py +288 -0
- ref_agents/tools/summary_generator.py +275 -0
- ref_agents/tools/symbol_resolver.py +306 -0
- ref_agents/tools/symbol_smoke_runner.py +336 -0
- ref_agents/tools/test_plan_validator.py +189 -0
- ref_agents/tools/test_smell_walker.py +902 -0
- ref_agents/tools/tier1_fixer.py +502 -0
- ref_agents/tools/validators/__init__.py +419 -0
- ref_agents/tools/validators/architect.py +268 -0
- ref_agents/tools/validators/cutover_engineer.py +167 -0
- ref_agents/tools/validators/developer.py +180 -0
- ref_agents/tools/validators/discovery.py +150 -0
- ref_agents/tools/validators/forensic_engineer.py +191 -0
- ref_agents/tools/validators/impact_architect.py +181 -0
- ref_agents/tools/validators/migration_planner.py +166 -0
- ref_agents/tools/validators/parity_tester.py +155 -0
- ref_agents/tools/validators/platform_engineer.py +134 -0
- ref_agents/tools/validators/pr_reviewer.py +129 -0
- ref_agents/tools/validators/product_manager.py +291 -0
- ref_agents/tools/validators/qa_lead.py +172 -0
- ref_agents/tools/validators/scrum_master.py +212 -0
- ref_agents/tools/validators/security_owner.py +162 -0
- ref_agents/tools/validators/specifier.py +134 -0
- ref_agents/tools/validators/strategist.py +149 -0
- ref_agents/tools/validators/tester.py +121 -0
- ref_agents/tools/version_manager.py +202 -0
- ref_agents/tools/workflow_tools.py +1549 -0
- ref_agents/utils/__init__.py +21 -0
- ref_agents/utils/git_utils.py +351 -0
- ref_agents/utils/handoff_logger.py +368 -0
- ref_agents/utils/ignore_matcher.py +270 -0
- ref_agents/workflow/__init__.py +19 -0
- ref_agents/workflow/capabilities.py +328 -0
- ref_agents/workflow/state_machine.py +708 -0
- ref_agents/workflow/transitions.py +658 -0
- ref_agents-1.0.0.dist-info/METADATA +365 -0
- ref_agents-1.0.0.dist-info/RECORD +175 -0
- ref_agents-1.0.0.dist-info/WHEEL +4 -0
- ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
- ref_agents-1.0.0.dist-info/licenses/LICENSE +115 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""Phase Transition Rules for REF Workflow.
|
|
2
|
+
|
|
3
|
+
Validates phase transitions based on artifact requirements
|
|
4
|
+
and determines next suggested agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from ref_agents.constants import (
|
|
14
|
+
AGENT_NEXT_SUGGESTION,
|
|
15
|
+
PHASE_CONFIG,
|
|
16
|
+
)
|
|
17
|
+
from ref_agents.workflow.state_machine import WorkflowManager, WorkflowState
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
# Health report older than this is treated as stale — forces re-scan.
|
|
22
|
+
_HEALTH_REPORT_MAX_AGE_SECONDS: int = 7200 # 2 hours
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_health_report_clean(project_root: Path) -> bool:
|
|
26
|
+
"""Check health_report.json for zero Critical/High violations.
|
|
27
|
+
|
|
28
|
+
Reads the latest health report. Returns False if report missing,
|
|
29
|
+
stale (>2h old), or contains Critical/High findings. Accepts
|
|
30
|
+
health_scan_deferred as an explicit audited bypass.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
project_root: Project root directory for report lookup.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True only if report exists, is fresh, and Critical==0 and High==0.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
from ref_agents.tools.compliance_remediation import _find_health_report_json
|
|
40
|
+
|
|
41
|
+
report_path = _find_health_report_json(str(project_root))
|
|
42
|
+
except Exception:
|
|
43
|
+
logger.warning("health_report_lookup_failed", project_root=str(project_root))
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
if report_path is None:
|
|
47
|
+
logger.info("health_report_missing", project_root=str(project_root))
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
age = time.time() - report_path.stat().st_mtime
|
|
52
|
+
if age > _HEALTH_REPORT_MAX_AGE_SECONDS:
|
|
53
|
+
logger.info(
|
|
54
|
+
"health_report_stale",
|
|
55
|
+
age_seconds=int(age),
|
|
56
|
+
max_age=_HEALTH_REPORT_MAX_AGE_SECONDS,
|
|
57
|
+
)
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
with report_path.open("r", encoding="utf-8") as fh:
|
|
61
|
+
data = json.load(fh)
|
|
62
|
+
|
|
63
|
+
critical = int(data.get("critical", data.get("Critical", 0)) or 0)
|
|
64
|
+
high = int(data.get("high", data.get("High", 0)) or 0)
|
|
65
|
+
|
|
66
|
+
clean = critical == 0 and high == 0
|
|
67
|
+
logger.info(
|
|
68
|
+
"health_report_checked",
|
|
69
|
+
critical=critical,
|
|
70
|
+
high=high,
|
|
71
|
+
clean=clean,
|
|
72
|
+
)
|
|
73
|
+
return clean
|
|
74
|
+
|
|
75
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
76
|
+
logger.warning("health_report_read_error", error=str(exc))
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_test_smell_clean(project_root: Path, state: WorkflowState) -> bool:
|
|
81
|
+
"""Verify zero CRITICAL test_smell findings on changed files (STORY-TQ-001 M1b).
|
|
82
|
+
|
|
83
|
+
Gate behaviour:
|
|
84
|
+
- Read latest ``health_report.json``.
|
|
85
|
+
- Filter to ``scanner == "test_smell"`` and ``severity == "critical"``.
|
|
86
|
+
- Intersect with files changed in the current git working tree.
|
|
87
|
+
- Pass when intersection is empty.
|
|
88
|
+
- Honour ``state.artifacts["tests_smell_deferred"]`` as an audited bypass.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
project_root: Project root for report + git lookup.
|
|
92
|
+
state: Active workflow state (carries deferral flag).
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True when the gate is clean OR a valid deferral is logged.
|
|
96
|
+
"""
|
|
97
|
+
if state.artifacts.get("tests_smell_deferred", False):
|
|
98
|
+
logger.info(
|
|
99
|
+
"test_smell_gate_deferred",
|
|
100
|
+
story_id=state.story_id,
|
|
101
|
+
)
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
from ref_agents.tools.compliance_remediation import _find_health_report_json
|
|
106
|
+
from ref_agents.utils.git_utils import get_changed_files
|
|
107
|
+
except ImportError as exc:
|
|
108
|
+
logger.warning("test_smell_gate_import_failed", error=str(exc))
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
report_path = _find_health_report_json(str(project_root))
|
|
112
|
+
if report_path is None:
|
|
113
|
+
logger.info("test_smell_gate_no_report", project_root=str(project_root))
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
age = time.time() - report_path.stat().st_mtime
|
|
118
|
+
if age > _HEALTH_REPORT_MAX_AGE_SECONDS:
|
|
119
|
+
logger.info("test_smell_gate_report_stale", age_seconds=int(age))
|
|
120
|
+
return False
|
|
121
|
+
with report_path.open("r", encoding="utf-8") as fh:
|
|
122
|
+
data = json.load(fh)
|
|
123
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
124
|
+
logger.warning("test_smell_gate_read_error", error=str(exc))
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
findings = data.get("findings", []) if isinstance(data, dict) else []
|
|
128
|
+
critical_smells = [
|
|
129
|
+
f
|
|
130
|
+
for f in findings
|
|
131
|
+
if isinstance(f, dict)
|
|
132
|
+
and f.get("scanner") == "test_smell"
|
|
133
|
+
and str(f.get("severity", "")).lower() == "critical"
|
|
134
|
+
]
|
|
135
|
+
if not critical_smells:
|
|
136
|
+
logger.info("test_smell_gate_clean", story_id=state.story_id)
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
changed = {str(p) for p in get_changed_files(str(project_root))}
|
|
140
|
+
if not changed:
|
|
141
|
+
# Detached HEAD or no diff — fall back to scan-all (Design Changed-Files section).
|
|
142
|
+
offending = [f.get("file", "") for f in critical_smells]
|
|
143
|
+
logger.warning(
|
|
144
|
+
"test_smell_gate_scan_all_fallback",
|
|
145
|
+
story_id=state.story_id,
|
|
146
|
+
offending=offending[:5],
|
|
147
|
+
)
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Match against both full and basename to absorb path-style mismatches.
|
|
151
|
+
changed_basenames = {Path(p).name for p in changed}
|
|
152
|
+
blocking = []
|
|
153
|
+
for f in critical_smells:
|
|
154
|
+
path_str = f.get("file", "")
|
|
155
|
+
if not isinstance(path_str, str) or not path_str:
|
|
156
|
+
continue
|
|
157
|
+
normalized = path_str
|
|
158
|
+
# Compare relative-to-project-root when path is absolute.
|
|
159
|
+
if path_str.startswith(str(project_root)):
|
|
160
|
+
normalized = path_str[len(str(project_root)) :].lstrip("/")
|
|
161
|
+
if normalized in changed or Path(path_str).name in changed_basenames:
|
|
162
|
+
blocking.append(f)
|
|
163
|
+
|
|
164
|
+
if blocking:
|
|
165
|
+
logger.info(
|
|
166
|
+
"test_smell_gate_blocked",
|
|
167
|
+
story_id=state.story_id,
|
|
168
|
+
blocking_count=len(blocking),
|
|
169
|
+
)
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
logger.info("test_smell_gate_clean_after_diff", story_id=state.story_id)
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def advisory_symbol_drift(
|
|
177
|
+
project_root: Path,
|
|
178
|
+
state: WorkflowState,
|
|
179
|
+
) -> list[str]:
|
|
180
|
+
"""Return advisory WARNING strings for missing grounded symbols (STORY-TQ-003).
|
|
181
|
+
|
|
182
|
+
Reads ``tests/plans/STORY-XXX.json`` for grounded ``symbol_under_test``
|
|
183
|
+
entries. Walks the AST of each changed file and checks whether each
|
|
184
|
+
declared symbol is defined (top-level ``def`` / ``class`` / class-method).
|
|
185
|
+
Emits a warning per missing symbol. **Never blocks the transition** —
|
|
186
|
+
advisory only in v1 per FR-13.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
project_root: Project root for plan + git diff lookup.
|
|
190
|
+
state: Active workflow state (story_id drives plan lookup).
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
List of warning strings. Empty when all grounded symbols are defined OR
|
|
194
|
+
when no grounded entries exist OR when no changed files are visible.
|
|
195
|
+
"""
|
|
196
|
+
plan_path = project_root / "tests" / "plans" / f"{state.story_id}.json"
|
|
197
|
+
if not plan_path.exists():
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
202
|
+
except (OSError, json.JSONDecodeError):
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
grounded = _grounded_symbols(plan)
|
|
206
|
+
if not grounded:
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
changed_files = _resolve_changed_python_files(project_root)
|
|
210
|
+
if not changed_files:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
declared = _collect_declared_symbols(changed_files)
|
|
214
|
+
warnings: list[str] = []
|
|
215
|
+
for entry in grounded:
|
|
216
|
+
name = entry["name"]
|
|
217
|
+
bare = name.split(".", 1)[0]
|
|
218
|
+
if bare in declared:
|
|
219
|
+
continue
|
|
220
|
+
warnings.append(
|
|
221
|
+
f"⚠️ symbol_drift advisory: `{name}` (from `{entry.get('module_path', 'unknown')}`) "
|
|
222
|
+
f"not found in changed files. Test case {entry['test_case_id']}. "
|
|
223
|
+
f"Grounding: {entry.get('grounding', 'n/a')}"
|
|
224
|
+
)
|
|
225
|
+
return warnings
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _grounded_symbols(plan: dict) -> list[dict]:
|
|
229
|
+
test_cases = plan.get("test_cases", []) if isinstance(plan, dict) else []
|
|
230
|
+
if not isinstance(test_cases, list):
|
|
231
|
+
return []
|
|
232
|
+
out: list[dict] = []
|
|
233
|
+
for tc in test_cases:
|
|
234
|
+
if not isinstance(tc, dict):
|
|
235
|
+
continue
|
|
236
|
+
sut = tc.get("symbol_under_test")
|
|
237
|
+
if not isinstance(sut, dict):
|
|
238
|
+
continue
|
|
239
|
+
if sut.get("source") not in ("code_map", "story_public_interface"):
|
|
240
|
+
continue
|
|
241
|
+
name = sut.get("name")
|
|
242
|
+
if not isinstance(name, str) or not name.strip():
|
|
243
|
+
continue
|
|
244
|
+
out.append(
|
|
245
|
+
{
|
|
246
|
+
"test_case_id": str(tc.get("id", "UNKNOWN")),
|
|
247
|
+
"name": name,
|
|
248
|
+
"module_path": sut.get("module_path"),
|
|
249
|
+
"grounding": sut.get("grounding"),
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _resolve_changed_python_files(project_root: Path) -> list[Path]:
|
|
256
|
+
try:
|
|
257
|
+
from ref_agents.utils.git_utils import get_changed_files
|
|
258
|
+
except ImportError:
|
|
259
|
+
return []
|
|
260
|
+
paths = get_changed_files(str(project_root))
|
|
261
|
+
out: list[Path] = []
|
|
262
|
+
for rel in paths:
|
|
263
|
+
full = project_root / rel
|
|
264
|
+
if full.suffix == ".py" and full.exists():
|
|
265
|
+
out.append(full)
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _collect_declared_symbols(paths: list[Path]) -> set[str]:
|
|
270
|
+
import ast as _ast
|
|
271
|
+
|
|
272
|
+
declared: set[str] = set()
|
|
273
|
+
for path in paths:
|
|
274
|
+
try:
|
|
275
|
+
tree = _ast.parse(path.read_text(encoding="utf-8"))
|
|
276
|
+
except (OSError, SyntaxError):
|
|
277
|
+
continue
|
|
278
|
+
for node in _ast.walk(tree):
|
|
279
|
+
if isinstance(
|
|
280
|
+
node, (_ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)
|
|
281
|
+
):
|
|
282
|
+
declared.add(node.name)
|
|
283
|
+
return declared
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _check_full_suite_clean(project_root: Path, state: WorkflowState) -> bool:
|
|
287
|
+
"""Verify the full-suite gate (STORY-TQ-002).
|
|
288
|
+
|
|
289
|
+
Honours:
|
|
290
|
+
- ``full_suite_deferred`` artifact: audited bypass (writes debt entry
|
|
291
|
+
elsewhere; gate returns True).
|
|
292
|
+
- ``greenfield_no_tests`` artifact: accepted only when the report status
|
|
293
|
+
is ``no_runners``.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
project_root: Project root for report lookup.
|
|
297
|
+
state: Active workflow state.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
True when the gate is clean OR a valid bypass is in place.
|
|
301
|
+
"""
|
|
302
|
+
if state.artifacts.get("full_suite_deferred", False):
|
|
303
|
+
logger.info("full_suite_gate_deferred", story_id=state.story_id)
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
from ref_agents.tools.full_suite_runner import evaluate_gate
|
|
308
|
+
except ImportError as exc:
|
|
309
|
+
logger.warning("full_suite_gate_import_failed", error=str(exc))
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
decision = evaluate_gate(project_root)
|
|
313
|
+
if decision.passed:
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
# Greenfield projects can bypass `no_runners` specifically — never other failures.
|
|
317
|
+
if (
|
|
318
|
+
decision.reason == "status_not_passed"
|
|
319
|
+
and decision.detail.get("status") == "no_runners"
|
|
320
|
+
and state.artifacts.get("greenfield_no_tests", False)
|
|
321
|
+
):
|
|
322
|
+
logger.info("full_suite_gate_greenfield_bypass", story_id=state.story_id)
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
logger.info(
|
|
326
|
+
"full_suite_gate_blocked",
|
|
327
|
+
story_id=state.story_id,
|
|
328
|
+
reason=decision.reason,
|
|
329
|
+
detail=decision.detail,
|
|
330
|
+
)
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class TransitionValidator:
|
|
335
|
+
"""Validates and executes phase transitions.
|
|
336
|
+
|
|
337
|
+
Attributes:
|
|
338
|
+
manager: WorkflowManager instance.
|
|
339
|
+
project_root: Root directory for artifact detection.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def __init__(
|
|
343
|
+
self,
|
|
344
|
+
manager: WorkflowManager | None = None,
|
|
345
|
+
project_root: Path | None = None,
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Initialize transition validator.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
manager: WorkflowManager instance. Creates default if None.
|
|
351
|
+
project_root: Project root for artifact detection.
|
|
352
|
+
"""
|
|
353
|
+
self.manager = manager or WorkflowManager()
|
|
354
|
+
self.project_root = project_root or Path(".")
|
|
355
|
+
|
|
356
|
+
def check_artifacts(self, state: WorkflowState, phase: str) -> dict[str, bool]:
|
|
357
|
+
"""Check if required artifacts exist for a phase.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
state: Current workflow state.
|
|
361
|
+
phase: Target phase ID.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Dict of artifact name -> exists flag.
|
|
365
|
+
"""
|
|
366
|
+
config = PHASE_CONFIG.get(phase, {})
|
|
367
|
+
required = config.get("required_artifacts", [])
|
|
368
|
+
|
|
369
|
+
results: dict[str, bool] = {}
|
|
370
|
+
|
|
371
|
+
for artifact in required:
|
|
372
|
+
if artifact in state.artifacts:
|
|
373
|
+
results[artifact] = state.artifacts[artifact]
|
|
374
|
+
elif artifact == "CODE_MAP.md":
|
|
375
|
+
results[artifact] = (self.project_root / "CODE_MAP.md").exists()
|
|
376
|
+
elif artifact.startswith("docs/specs/"):
|
|
377
|
+
# Substitute {story_id} placeholder before checking file existence
|
|
378
|
+
resolved = artifact.replace("{story_id}", state.story_id)
|
|
379
|
+
results[artifact] = (self.project_root / resolved).exists()
|
|
380
|
+
elif artifact.startswith("requirements/"):
|
|
381
|
+
req_dir = self.project_root / "docs" / "requirements"
|
|
382
|
+
results[artifact] = req_dir.exists() and any(req_dir.glob("*.md"))
|
|
383
|
+
elif artifact == "conceptual_tests":
|
|
384
|
+
# Check for tests/plans/STORY-XXX.json file
|
|
385
|
+
plan_file = (
|
|
386
|
+
self.project_root / "tests" / "plans" / f"{state.story_id}.json"
|
|
387
|
+
)
|
|
388
|
+
results[artifact] = plan_file.exists() or state.artifacts.get(
|
|
389
|
+
artifact, False
|
|
390
|
+
)
|
|
391
|
+
elif artifact == "health_scan_passed":
|
|
392
|
+
# Deferred bypass (audited escape hatch) takes precedence over disk check.
|
|
393
|
+
if state.artifacts.get("health_scan_deferred", False):
|
|
394
|
+
results[artifact] = True
|
|
395
|
+
else:
|
|
396
|
+
results[artifact] = _check_health_report_clean(self.project_root)
|
|
397
|
+
elif artifact == "tests_smell_passed":
|
|
398
|
+
# STORY-TQ-001 M1b — gate code present, not wired into required_artifacts yet.
|
|
399
|
+
# Ship-2 promotes by appending to REVIEW required_artifacts.
|
|
400
|
+
results[artifact] = _check_test_smell_clean(self.project_root, state)
|
|
401
|
+
elif artifact == "full_suite_passed":
|
|
402
|
+
# STORY-TQ-002 M2a: gate code present, NOT wired into
|
|
403
|
+
# required_artifacts yet. M2b promotes by editing constants.py.
|
|
404
|
+
results[artifact] = _check_full_suite_clean(self.project_root, state)
|
|
405
|
+
elif artifact in (
|
|
406
|
+
"ac_validated",
|
|
407
|
+
"design",
|
|
408
|
+
"implementation",
|
|
409
|
+
"codemap_updated",
|
|
410
|
+
"review_passed",
|
|
411
|
+
):
|
|
412
|
+
results[artifact] = state.artifacts.get(artifact, False)
|
|
413
|
+
elif artifact in ("tests_passed", "security_passed"):
|
|
414
|
+
if state.parallel_status:
|
|
415
|
+
tasks = state.parallel_status.get("tasks", {})
|
|
416
|
+
if artifact == "tests_passed":
|
|
417
|
+
results[artifact] = tasks.get("tester") == "passed"
|
|
418
|
+
else:
|
|
419
|
+
results[artifact] = tasks.get("security_owner") == "passed"
|
|
420
|
+
else:
|
|
421
|
+
results[artifact] = state.artifacts.get(artifact, False)
|
|
422
|
+
else:
|
|
423
|
+
results[artifact] = state.artifacts.get(artifact, False)
|
|
424
|
+
|
|
425
|
+
return results
|
|
426
|
+
|
|
427
|
+
def can_transition(self, story_id: str, target_phase: str) -> tuple[bool, str]:
|
|
428
|
+
"""Check if transition to target phase is valid.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
story_id: Story identifier.
|
|
432
|
+
target_phase: Target phase ID.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Tuple of (can_transition, reason).
|
|
436
|
+
"""
|
|
437
|
+
state = self.manager.get_state(story_id)
|
|
438
|
+
if not state:
|
|
439
|
+
return False, f"No workflow state found for {story_id}"
|
|
440
|
+
|
|
441
|
+
if target_phase not in PHASE_CONFIG:
|
|
442
|
+
return False, f"Invalid phase: {target_phase}"
|
|
443
|
+
|
|
444
|
+
current_config = PHASE_CONFIG.get(state.current_phase, {})
|
|
445
|
+
if current_config.get("next_phase") != target_phase:
|
|
446
|
+
# Allow skipping back or jumping if artifacts exist
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
# Check required artifacts
|
|
450
|
+
artifact_status = self.check_artifacts(state, target_phase)
|
|
451
|
+
missing = [name for name, exists in artifact_status.items() if not exists]
|
|
452
|
+
|
|
453
|
+
if missing:
|
|
454
|
+
return False, f"Missing artifacts: {', '.join(missing)}"
|
|
455
|
+
|
|
456
|
+
# Check for blockers
|
|
457
|
+
if state.blockers:
|
|
458
|
+
return False, f"Active blockers: {len(state.blockers)}"
|
|
459
|
+
|
|
460
|
+
return True, "All requirements met"
|
|
461
|
+
|
|
462
|
+
def transition(self, story_id: str, target_phase: str) -> WorkflowState:
|
|
463
|
+
"""Execute transition to target phase.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
story_id: Story identifier.
|
|
467
|
+
target_phase: Target phase ID.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
Updated WorkflowState.
|
|
471
|
+
|
|
472
|
+
Raises:
|
|
473
|
+
ValueError: If transition not valid.
|
|
474
|
+
"""
|
|
475
|
+
can_trans, reason = self.can_transition(story_id, target_phase)
|
|
476
|
+
if not can_trans:
|
|
477
|
+
raise ValueError(f"Cannot transition: {reason}")
|
|
478
|
+
|
|
479
|
+
return self.manager.update_phase(story_id, target_phase)
|
|
480
|
+
|
|
481
|
+
def get_next_phase(self, story_id: str) -> str | None:
|
|
482
|
+
"""Get the next phase for a story.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
story_id: Story identifier.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Next phase ID or None if at end.
|
|
489
|
+
"""
|
|
490
|
+
state = self.manager.get_state(story_id)
|
|
491
|
+
if not state:
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
config = PHASE_CONFIG.get(state.current_phase, {})
|
|
495
|
+
return config.get("next_phase")
|
|
496
|
+
|
|
497
|
+
def suggest_next_agent(
|
|
498
|
+
self,
|
|
499
|
+
story_id: str,
|
|
500
|
+
completed_task: str = "",
|
|
501
|
+
) -> dict:
|
|
502
|
+
"""Suggest next agent after task completion.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
story_id: Story identifier.
|
|
506
|
+
completed_task: Description of completed task.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Dict with suggestion details.
|
|
510
|
+
"""
|
|
511
|
+
state = self.manager.get_state(story_id)
|
|
512
|
+
if not state:
|
|
513
|
+
return {
|
|
514
|
+
"error": f"No workflow state found for {story_id}",
|
|
515
|
+
"suggestion": None,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
current_agent = state.active_agents[0] if state.active_agents else None
|
|
519
|
+
current_config = PHASE_CONFIG.get(state.current_phase, {})
|
|
520
|
+
|
|
521
|
+
# Check for background notifications
|
|
522
|
+
notifications = []
|
|
523
|
+
if state.parallel_status:
|
|
524
|
+
notifications = state.parallel_status.get("notifications", [])
|
|
525
|
+
|
|
526
|
+
next_suggestion = AGENT_NEXT_SUGGESTION.get(current_agent or "")
|
|
527
|
+
|
|
528
|
+
is_parallel = isinstance(next_suggestion, list)
|
|
529
|
+
parallel_agents = next_suggestion if is_parallel else None
|
|
530
|
+
|
|
531
|
+
background_agent = current_config.get("background_agent")
|
|
532
|
+
background_notification = None
|
|
533
|
+
if background_agent and notifications:
|
|
534
|
+
background_notification = notifications[-1] if notifications else None
|
|
535
|
+
|
|
536
|
+
# Gate: validate current phase artifacts before allowing next agent suggestion.
|
|
537
|
+
# Without this check, workflow skips phases when artifacts (e.g. codemap_updated,
|
|
538
|
+
# implementation) are never set by the agent.
|
|
539
|
+
next_phase = current_config.get("next_phase")
|
|
540
|
+
missing_artifacts: list[str] = []
|
|
541
|
+
if next_suggestion is not None and next_phase:
|
|
542
|
+
artifact_status = self.check_artifacts(state, next_phase)
|
|
543
|
+
missing_artifacts = [k for k, v in artifact_status.items() if not v]
|
|
544
|
+
|
|
545
|
+
if missing_artifacts:
|
|
546
|
+
return {
|
|
547
|
+
"story_id": story_id,
|
|
548
|
+
"current_phase": state.current_phase,
|
|
549
|
+
"current_agent": current_agent,
|
|
550
|
+
"completed_task": completed_task,
|
|
551
|
+
"next_agent": None,
|
|
552
|
+
"is_parallel": False,
|
|
553
|
+
"parallel_agents": None,
|
|
554
|
+
"background_notification": None,
|
|
555
|
+
"notifications": notifications,
|
|
556
|
+
"reason": f"Blocked — missing artifacts: {', '.join(missing_artifacts)}",
|
|
557
|
+
"blocked": True,
|
|
558
|
+
"missing_artifacts": missing_artifacts,
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
# Determine single next agent
|
|
562
|
+
if is_parallel:
|
|
563
|
+
next_agent = next_suggestion # List of parallel agents
|
|
564
|
+
else:
|
|
565
|
+
next_agent = next_suggestion
|
|
566
|
+
|
|
567
|
+
advisory_warnings = advisory_symbol_drift(self.project_root, state)
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
"story_id": story_id,
|
|
571
|
+
"current_phase": state.current_phase,
|
|
572
|
+
"current_agent": current_agent,
|
|
573
|
+
"completed_task": completed_task,
|
|
574
|
+
"next_agent": next_agent,
|
|
575
|
+
"is_parallel": is_parallel,
|
|
576
|
+
"parallel_agents": parallel_agents,
|
|
577
|
+
"background_notification": background_notification,
|
|
578
|
+
"notifications": notifications,
|
|
579
|
+
"reason": self._get_suggestion_reason(current_agent, next_agent),
|
|
580
|
+
"warnings": advisory_warnings,
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
def _get_suggestion_reason(
|
|
584
|
+
self,
|
|
585
|
+
current_agent: str | None,
|
|
586
|
+
next_agent: str | list | None,
|
|
587
|
+
) -> str:
|
|
588
|
+
"""Generate reason for agent suggestion.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
current_agent: Current agent name.
|
|
592
|
+
next_agent: Suggested next agent(s).
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Human-readable reason.
|
|
596
|
+
"""
|
|
597
|
+
if not next_agent:
|
|
598
|
+
return "Workflow complete"
|
|
599
|
+
|
|
600
|
+
if isinstance(next_agent, list):
|
|
601
|
+
agents_str = " + ".join(next_agent)
|
|
602
|
+
return f"Ready for parallel validation: {agents_str}"
|
|
603
|
+
|
|
604
|
+
reasons = {
|
|
605
|
+
"product_manager": "CODE_MAP ready → Define requirements",
|
|
606
|
+
"qa_lead": "Requirements ready → Validate AC",
|
|
607
|
+
"test_designer": "Requirements ready → Design conceptual tests",
|
|
608
|
+
"architect": "AC validated → Design system",
|
|
609
|
+
"developer": "QA Gate passed → Implement",
|
|
610
|
+
"pr_reviewer": "Implementation ready → Review code",
|
|
611
|
+
"tester": "Review passed → Write executable tests",
|
|
612
|
+
"security_owner": "Review passed → Security audit",
|
|
613
|
+
"scrum_master": "All validations passed → Close loop",
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return reasons.get(next_agent, f"Proceed to {next_agent}")
|
|
617
|
+
|
|
618
|
+
def get_phase_status(self, story_id: str) -> dict:
|
|
619
|
+
"""Get current phase status summary.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
story_id: Story identifier.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
Dict with phase status details.
|
|
626
|
+
"""
|
|
627
|
+
state = self.manager.get_state(story_id)
|
|
628
|
+
if not state:
|
|
629
|
+
return {"error": f"No workflow state found for {story_id}"}
|
|
630
|
+
|
|
631
|
+
current_config = PHASE_CONFIG.get(state.current_phase, {})
|
|
632
|
+
artifact_status = self.check_artifacts(state, state.current_phase)
|
|
633
|
+
|
|
634
|
+
# Check next phase readiness
|
|
635
|
+
next_phase = current_config.get("next_phase")
|
|
636
|
+
next_ready = False
|
|
637
|
+
next_missing = []
|
|
638
|
+
|
|
639
|
+
if next_phase:
|
|
640
|
+
next_artifact_status = self.check_artifacts(state, next_phase)
|
|
641
|
+
next_missing = [k for k, v in next_artifact_status.items() if not v]
|
|
642
|
+
next_ready = len(next_missing) == 0
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
"story_id": story_id,
|
|
646
|
+
"story_type": state.story_type,
|
|
647
|
+
"current_phase": state.current_phase,
|
|
648
|
+
"phase_name": current_config.get("name", "Unknown"),
|
|
649
|
+
"active_agents": state.active_agents,
|
|
650
|
+
"completed_phases": state.completed_phases,
|
|
651
|
+
"artifacts": artifact_status,
|
|
652
|
+
"next_phase": next_phase,
|
|
653
|
+
"next_phase_ready": next_ready,
|
|
654
|
+
"next_missing_artifacts": next_missing,
|
|
655
|
+
"parallel_status": state.parallel_status,
|
|
656
|
+
"blockers": state.blockers,
|
|
657
|
+
"capabilities": state.capabilities,
|
|
658
|
+
}
|