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.
Files changed (175) hide show
  1. ref_agents/__init__.py +9 -0
  2. ref_agents/api_keys.json.example +8 -0
  3. ref_agents/auth.py +129 -0
  4. ref_agents/codemap/..md +62 -0
  5. ref_agents/codemap/CODE_MAP.md +37 -0
  6. ref_agents/codemap/core.md +43 -0
  7. ref_agents/codemap/models.md +43 -0
  8. ref_agents/codemap/prompts.md +40 -0
  9. ref_agents/codemap/security.md +45 -0
  10. ref_agents/codemap/tools.md +94 -0
  11. ref_agents/codemap/tools_browser.md +44 -0
  12. ref_agents/codemap/utils.md +42 -0
  13. ref_agents/codemap/workflow.md +42 -0
  14. ref_agents/config/ai_patterns.yaml +101 -0
  15. ref_agents/config/frameworks/angular.yaml +104 -0
  16. ref_agents/config/frameworks/aspnet.yaml +84 -0
  17. ref_agents/config/frameworks/ef_core.yaml +81 -0
  18. ref_agents/config/frameworks/react.yaml +111 -0
  19. ref_agents/config/frameworks/spring_boot.yaml +117 -0
  20. ref_agents/config/languages/csharp.yaml +153 -0
  21. ref_agents/config/languages/java.yaml +188 -0
  22. ref_agents/config/languages/javascript.yaml +172 -0
  23. ref_agents/config/languages/python.yaml +153 -0
  24. ref_agents/config/languages/typescript.yaml +193 -0
  25. ref_agents/constants.py +553 -0
  26. ref_agents/core/__init__.py +15 -0
  27. ref_agents/core/config_loader.py +160 -0
  28. ref_agents/core/config_models.py +167 -0
  29. ref_agents/core/config_parsing.py +84 -0
  30. ref_agents/core/language_detector.py +388 -0
  31. ref_agents/core/validation_models.py +66 -0
  32. ref_agents/core/validation_primitives.py +176 -0
  33. ref_agents/errors.py +34 -0
  34. ref_agents/license_client.py +247 -0
  35. ref_agents/models/__init__.py +22 -0
  36. ref_agents/models/gherkin.py +45 -0
  37. ref_agents/models/hierarchy.py +80 -0
  38. ref_agents/models/invest.py +59 -0
  39. ref_agents/models/version.py +49 -0
  40. ref_agents/prompts/__init__.py +9 -0
  41. ref_agents/prompts/start_agent.py +772 -0
  42. ref_agents/rules/architecture/backend_patterns.md +43 -0
  43. ref_agents/rules/architecture/diagramming.md +100 -0
  44. ref_agents/rules/architecture/frontend_patterns.md +40 -0
  45. ref_agents/rules/architecture/impact_analysis.md +129 -0
  46. ref_agents/rules/architecture/migration_strategy.md +208 -0
  47. ref_agents/rules/architecture/regression_protocol.md +77 -0
  48. ref_agents/rules/architecture/system_design.md +97 -0
  49. ref_agents/rules/common/codemap_standard.md +97 -0
  50. ref_agents/rules/common/core_protocol.md +59 -0
  51. ref_agents/rules/common/prompt_engineering.md +294 -0
  52. ref_agents/rules/development/debugging.md +32 -0
  53. ref_agents/rules/development/implementation.md +205 -0
  54. ref_agents/rules/operations/completion.md +119 -0
  55. ref_agents/rules/operations/cutover_protocol.md +218 -0
  56. ref_agents/rules/operations/discovery.md +179 -0
  57. ref_agents/rules/operations/fix_workflow.md +87 -0
  58. ref_agents/rules/operations/forensics.md +278 -0
  59. ref_agents/rules/operations/platform.md +263 -0
  60. ref_agents/rules/operations/synchronous_flow.md +25 -0
  61. ref_agents/rules/product/ac_validation.md +25 -0
  62. ref_agents/rules/product/brainstorming.md +27 -0
  63. ref_agents/rules/product/ref_flow.md +101 -0
  64. ref_agents/rules/product/requirements_std.md +114 -0
  65. ref_agents/rules/product/spec_writing.md +235 -0
  66. ref_agents/rules/product/strategy.md +96 -0
  67. ref_agents/rules/quality/documentation_standards.md +46 -0
  68. ref_agents/rules/quality/parity_testing.md +234 -0
  69. ref_agents/rules/quality/project_documentation.md +56 -0
  70. ref_agents/rules/quality/qa_lead.md +111 -0
  71. ref_agents/rules/quality/test_design.md +146 -0
  72. ref_agents/rules/quality/testing_standards.md +293 -0
  73. ref_agents/rules/review/pr_review.md +116 -0
  74. ref_agents/rules/security/security_audit.md +83 -0
  75. ref_agents/security/__init__.py +22 -0
  76. ref_agents/security/dependency_audit.py +188 -0
  77. ref_agents/security/file_audit.py +208 -0
  78. ref_agents/security/network_scan.py +179 -0
  79. ref_agents/security/report_generator.py +313 -0
  80. ref_agents/security/secret_scan.py +252 -0
  81. ref_agents/security/url_scan.py +240 -0
  82. ref_agents/security_scan.py +236 -0
  83. ref_agents/server.py +1586 -0
  84. ref_agents/session.py +100 -0
  85. ref_agents/tool_names.py +55 -0
  86. ref_agents/tools/__init__.py +8 -0
  87. ref_agents/tools/agents_generator.py +315 -0
  88. ref_agents/tools/ai_pattern_detector.py +815 -0
  89. ref_agents/tools/brownfield_populator.py +529 -0
  90. ref_agents/tools/browser/__init__.py +50 -0
  91. ref_agents/tools/browser/evidence_verifier.py +302 -0
  92. ref_agents/tools/browser/execution_logger.py +249 -0
  93. ref_agents/tools/browser/playwright_mcp_client.py +259 -0
  94. ref_agents/tools/browser/screenshot_utils.py +184 -0
  95. ref_agents/tools/browser/test_executor.py +537 -0
  96. ref_agents/tools/code_quality_scanner.py +629 -0
  97. ref_agents/tools/codemap/..md +93 -0
  98. ref_agents/tools/codemap/CODE_MAP.md +30 -0
  99. ref_agents/tools/codemap/browser.md +44 -0
  100. ref_agents/tools/codemap.py +403 -0
  101. ref_agents/tools/codemap_freshness.py +234 -0
  102. ref_agents/tools/comment_smell_scanner.py +346 -0
  103. ref_agents/tools/complexity.py +436 -0
  104. ref_agents/tools/complexity_ast.py +333 -0
  105. ref_agents/tools/compliance.py +246 -0
  106. ref_agents/tools/compliance_remediation.py +846 -0
  107. ref_agents/tools/context_graph.py +839 -0
  108. ref_agents/tools/context_manager.py +550 -0
  109. ref_agents/tools/context_tools.py +121 -0
  110. ref_agents/tools/cross_repo_linker.py +393 -0
  111. ref_agents/tools/dead_code_scanner.py +637 -0
  112. ref_agents/tools/debt_scanner.py +1092 -0
  113. ref_agents/tools/dependency_graph.py +272 -0
  114. ref_agents/tools/discovery_audit.py +372 -0
  115. ref_agents/tools/docs_scanner.py +600 -0
  116. ref_agents/tools/evaluate_gate.py +119 -0
  117. ref_agents/tools/external_detector.py +524 -0
  118. ref_agents/tools/features_generator.py +282 -0
  119. ref_agents/tools/flow_gap_detector.py +373 -0
  120. ref_agents/tools/flow_mapper.py +327 -0
  121. ref_agents/tools/full_suite_runner.py +740 -0
  122. ref_agents/tools/gherkin_parser.py +227 -0
  123. ref_agents/tools/guard_tools.py +139 -0
  124. ref_agents/tools/handoff_tools.py +282 -0
  125. ref_agents/tools/health_scanner.py +1211 -0
  126. ref_agents/tools/hierarchy_manager.py +289 -0
  127. ref_agents/tools/invest_scorer.py +249 -0
  128. ref_agents/tools/jira_confluence_export.py +306 -0
  129. ref_agents/tools/json_output.py +76 -0
  130. ref_agents/tools/migration_mapper.py +946 -0
  131. ref_agents/tools/migration_readiness_scanner.py +209 -0
  132. ref_agents/tools/pattern_learner.py +522 -0
  133. ref_agents/tools/report_utils.py +155 -0
  134. ref_agents/tools/requirements_serializer.py +225 -0
  135. ref_agents/tools/security_audit_tool.py +106 -0
  136. ref_agents/tools/sequencing_engine.py +288 -0
  137. ref_agents/tools/summary_generator.py +275 -0
  138. ref_agents/tools/symbol_resolver.py +306 -0
  139. ref_agents/tools/symbol_smoke_runner.py +336 -0
  140. ref_agents/tools/test_plan_validator.py +189 -0
  141. ref_agents/tools/test_smell_walker.py +902 -0
  142. ref_agents/tools/tier1_fixer.py +502 -0
  143. ref_agents/tools/validators/__init__.py +419 -0
  144. ref_agents/tools/validators/architect.py +268 -0
  145. ref_agents/tools/validators/cutover_engineer.py +167 -0
  146. ref_agents/tools/validators/developer.py +180 -0
  147. ref_agents/tools/validators/discovery.py +150 -0
  148. ref_agents/tools/validators/forensic_engineer.py +191 -0
  149. ref_agents/tools/validators/impact_architect.py +181 -0
  150. ref_agents/tools/validators/migration_planner.py +166 -0
  151. ref_agents/tools/validators/parity_tester.py +155 -0
  152. ref_agents/tools/validators/platform_engineer.py +134 -0
  153. ref_agents/tools/validators/pr_reviewer.py +129 -0
  154. ref_agents/tools/validators/product_manager.py +291 -0
  155. ref_agents/tools/validators/qa_lead.py +172 -0
  156. ref_agents/tools/validators/scrum_master.py +212 -0
  157. ref_agents/tools/validators/security_owner.py +162 -0
  158. ref_agents/tools/validators/specifier.py +134 -0
  159. ref_agents/tools/validators/strategist.py +149 -0
  160. ref_agents/tools/validators/tester.py +121 -0
  161. ref_agents/tools/version_manager.py +202 -0
  162. ref_agents/tools/workflow_tools.py +1549 -0
  163. ref_agents/utils/__init__.py +21 -0
  164. ref_agents/utils/git_utils.py +351 -0
  165. ref_agents/utils/handoff_logger.py +368 -0
  166. ref_agents/utils/ignore_matcher.py +270 -0
  167. ref_agents/workflow/__init__.py +19 -0
  168. ref_agents/workflow/capabilities.py +328 -0
  169. ref_agents/workflow/state_machine.py +708 -0
  170. ref_agents/workflow/transitions.py +658 -0
  171. ref_agents-1.0.0.dist-info/METADATA +365 -0
  172. ref_agents-1.0.0.dist-info/RECORD +175 -0
  173. ref_agents-1.0.0.dist-info/WHEEL +4 -0
  174. ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
  175. 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
+ }