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,212 @@
1
+ """Scrum Master closure validator — validate_closure.
2
+
3
+ Verifies that a story's closure preconditions are met:
4
+ - All required phase rows present + ✅ in closure doc
5
+ - Codemap freshness vs story creation
6
+ - handoff_log.jsonl has phase entries
7
+
8
+ Registered under role "scrum_master".
9
+
10
+ See SPEC-STORY-025 / DESIGN-STORY-025.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ import structlog
19
+
20
+ from ref_agents.core.validation_models import (
21
+ Gap,
22
+ ValidationReport,
23
+ Verdict,
24
+ )
25
+ from ref_agents.session import SessionManager
26
+ from ref_agents.tools.validators import register_validator
27
+
28
+ logger = structlog.get_logger(__name__)
29
+
30
+ REQUIRED_PHASES = (
31
+ "requirements",
32
+ "qa_lead",
33
+ "specifier",
34
+ "architect",
35
+ "developer",
36
+ "pr_reviewer",
37
+ "tester",
38
+ "security_owner",
39
+ "scrum_master",
40
+ )
41
+
42
+ # Map closure-doc display rows to canonical phase keys
43
+ ROW_ALIASES = {
44
+ "requirements": "requirements",
45
+ "qa gate": "qa_lead",
46
+ "qa lead": "qa_lead",
47
+ "specification": "specifier",
48
+ "spec": "specifier",
49
+ "design": "architect",
50
+ "implementation": "developer",
51
+ "review": "pr_reviewer",
52
+ "testing": "tester",
53
+ "security": "security_owner",
54
+ "scrum master": "scrum_master",
55
+ }
56
+
57
+
58
+ def _resolve_under_root(candidate: Path) -> Path | None:
59
+ root = SessionManager.get().project_root
60
+ candidate = candidate.resolve()
61
+ if root is not None:
62
+ try:
63
+ candidate.relative_to(root.resolve())
64
+ except ValueError:
65
+ return None
66
+ return candidate if candidate.exists() else None
67
+
68
+
69
+ def _resolve_closure(artifact_id: str, artifact_path: str) -> Path | None:
70
+ if artifact_path:
71
+ return _resolve_under_root(Path(artifact_path))
72
+ root = SessionManager.get().project_root
73
+ if artifact_id and root is not None:
74
+ return _resolve_under_root(
75
+ root / "docs" / "closure" / f"{artifact_id}-CLOSURE.md"
76
+ )
77
+ return None
78
+
79
+
80
+ def _phase_status_from_closure(text: str) -> dict[str, str]:
81
+ """Parse closure doc table rows → {phase_key: marker}.
82
+
83
+ Marker is the first emoji-like token in the status cell.
84
+ """
85
+ statuses: dict[str, str] = {}
86
+ for line in text.splitlines():
87
+ if not line.startswith("|"):
88
+ continue
89
+ cells = [c.strip() for c in line.strip("|").split("|")]
90
+ if len(cells) < 2: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
91
+ continue
92
+ first = cells[0].lower().strip()
93
+ for alias, canonical in ROW_ALIASES.items():
94
+ if alias in first:
95
+ statuses[canonical] = cells[1].strip()
96
+ break
97
+ return statuses
98
+
99
+
100
+ def _codemap_max_mtime(root: Path) -> float:
101
+ codemap_dir = root / "codemap"
102
+ if not codemap_dir.exists():
103
+ return 0.0
104
+ mtimes = [p.stat().st_mtime for p in codemap_dir.rglob("*.md")]
105
+ return max(mtimes) if mtimes else 0.0
106
+
107
+
108
+ def _story_creation_mtime(root: Path, artifact_id: str) -> float | None:
109
+ story_path = root / "docs" / "requirements" / f"{artifact_id}.md"
110
+ if not story_path.exists():
111
+ return None
112
+ return story_path.stat().st_mtime
113
+
114
+
115
+ def _handoff_log_phases(root: Path, artifact_id: str) -> set[str]:
116
+ """Return set of phase keys logged for this story in handoff_log.jsonl."""
117
+ log_path = root / "ref-reports" / "handoff_log.jsonl"
118
+ if not log_path.exists():
119
+ return set()
120
+ phases: set[str] = set()
121
+ with log_path.open("r", encoding="utf-8") as f:
122
+ for line in f:
123
+ line = line.strip()
124
+ if not line:
125
+ continue
126
+ try:
127
+ entry = json.loads(line)
128
+ except json.JSONDecodeError:
129
+ continue
130
+ if entry.get("story_id") != artifact_id:
131
+ continue
132
+ if entry.get("event") == "phase":
133
+ phase = entry.get("phase")
134
+ if isinstance(phase, str):
135
+ phases.add(phase)
136
+ return phases
137
+
138
+
139
+ @register_validator("scrum_master")
140
+ def validate_closure(
141
+ artifact_id: str = "", artifact_path: str = ""
142
+ ) -> ValidationReport:
143
+ """Validate a story closure document and surrounding state."""
144
+ log = logger.bind(role="scrum_master", artifact_id=artifact_id)
145
+
146
+ closure_path = _resolve_closure(artifact_id, artifact_path)
147
+ if closure_path is None:
148
+ return ValidationReport(
149
+ verdict=Verdict.NEEDS_USER,
150
+ gaps=[
151
+ Gap(code="closure_doc_unresolved", detail=artifact_id or artifact_path)
152
+ ],
153
+ )
154
+
155
+ text = closure_path.read_text(encoding="utf-8")
156
+ statuses = _phase_status_from_closure(text)
157
+ gaps: list[Gap] = []
158
+
159
+ # FR-1 + FR-2: phase presence + marker
160
+ for phase in REQUIRED_PHASES:
161
+ marker = statuses.get(phase)
162
+ if marker is None:
163
+ gaps.append(
164
+ Gap(
165
+ code=f"closure_phase_missing:{phase}",
166
+ detail=f"No row for phase '{phase}' in closure doc.",
167
+ )
168
+ )
169
+ continue
170
+ if "✅" not in marker:
171
+ gaps.append(
172
+ Gap(
173
+ code=f"closure_phase_not_passed:{phase}",
174
+ detail=f"Phase '{phase}' marker is '{marker}', expected ✅.",
175
+ )
176
+ )
177
+
178
+ root = SessionManager.get().project_root
179
+ if root is not None:
180
+ # FR-3: codemap freshness
181
+ story_mtime = _story_creation_mtime(root, artifact_id)
182
+ if story_mtime is not None:
183
+ codemap_mtime = _codemap_max_mtime(root)
184
+ if codemap_mtime < story_mtime:
185
+ gaps.append(
186
+ Gap(
187
+ code="codemap_stale",
188
+ detail=(
189
+ f"Codemap last update ({codemap_mtime}) is older "
190
+ f"than story creation ({story_mtime})."
191
+ ),
192
+ )
193
+ )
194
+
195
+ # FR-4: handoff log phase entries
196
+ logged = _handoff_log_phases(root, artifact_id)
197
+ for phase in REQUIRED_PHASES:
198
+ if phase not in logged and phase != "requirements":
199
+ # 'requirements' often logged as 'product_manager'
200
+ if phase == "scrum_master" and "scrum_master" in logged:
201
+ continue
202
+ if phase not in logged:
203
+ gaps.append(
204
+ Gap(
205
+ code=f"handoff_log_incomplete:{phase}",
206
+ detail=(f"No 'phase' event for '{phase}' in handoff_log."),
207
+ )
208
+ )
209
+
210
+ verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
211
+ log.info("scrum_validate_complete", verdict=verdict.value, gap_count=len(gaps))
212
+ return ValidationReport(verdict=verdict, gaps=gaps)
@@ -0,0 +1,162 @@
1
+ """Security Owner output validator — validate_security_report.
2
+
3
+ Checks finding table schema, severity ↔ verdict consistency, and the
4
+ "CRITICAL forces HALT" invariant.
5
+
6
+ See SPEC-STORY-031.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import structlog
15
+
16
+ from ref_agents.core.validation_models import (
17
+ Gap,
18
+ ValidationReport,
19
+ Verdict,
20
+ )
21
+ from ref_agents.session import SessionManager
22
+ from ref_agents.tools.validators import register_validator
23
+
24
+ logger = structlog.get_logger(__name__)
25
+
26
+ SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW")
27
+ VERDICTS = ("PASS", "CONDITIONAL", "FAIL", "HALT")
28
+
29
+
30
+ def _resolve_under_root(candidate: Path) -> Path | None:
31
+ root = SessionManager.get().project_root
32
+ candidate = candidate.resolve()
33
+ if root is not None:
34
+ try:
35
+ candidate.relative_to(root.resolve())
36
+ except ValueError:
37
+ return None
38
+ return candidate if candidate.exists() else None
39
+
40
+
41
+ def _resolve_security_report(artifact_id: str, artifact_path: str) -> Path | None:
42
+ if artifact_path:
43
+ return _resolve_under_root(Path(artifact_path))
44
+ root = SessionManager.get().project_root
45
+ if artifact_id and root is not None:
46
+ return _resolve_under_root(
47
+ root / "docs" / "analysis" / f"SECURITY-{artifact_id}.md"
48
+ )
49
+ return None
50
+
51
+
52
+ def _extract_verdict(text: str) -> str | None:
53
+ match = re.search(
54
+ r"verdict[:*\s_]*\*{0,2}(PASS|CONDITIONAL|FAIL|HALT)\*{0,2}",
55
+ text,
56
+ flags=re.IGNORECASE,
57
+ )
58
+ if match:
59
+ return match.group(1).upper()
60
+ return None
61
+
62
+
63
+ def _count_findings_by_severity(text: str) -> dict[str, int]:
64
+ """Count findings per severity by scanning table rows."""
65
+ counts: dict[str, int] = {s: 0 for s in SEVERITIES}
66
+ for line in text.splitlines():
67
+ if not line.startswith("|"):
68
+ continue
69
+ cells = [c.strip() for c in line.strip("|").split("|")]
70
+ for cell in cells:
71
+ up = cell.upper().strip("*_` ")
72
+ if up in SEVERITIES:
73
+ counts[up] += 1
74
+ return counts
75
+
76
+
77
+ def _extract_summary_counts(text: str) -> dict[str, int] | None:
78
+ """Parse 'Critical: N | High: N | Medium: N | Low: N' line if present."""
79
+ match = re.search(
80
+ r"Critical[:\s]+(\d+).*?High[:\s]+(\d+).*?Medium[:\s]+(\d+).*?Low[:\s]+(\d+)",
81
+ text,
82
+ flags=re.IGNORECASE | re.DOTALL,
83
+ )
84
+ if not match:
85
+ return None
86
+ return {
87
+ "CRITICAL": int(match.group(1)),
88
+ "HIGH": int(match.group(2)),
89
+ "MEDIUM": int(match.group(3)),
90
+ "LOW": int(match.group(4)),
91
+ }
92
+
93
+
94
+ @register_validator("security_owner")
95
+ def validate_security_report(
96
+ artifact_id: str = "", artifact_path: str = ""
97
+ ) -> ValidationReport:
98
+ log = logger.bind(role="security_owner", artifact_id=artifact_id)
99
+
100
+ path = _resolve_security_report(artifact_id, artifact_path)
101
+ if path is None:
102
+ return ValidationReport(
103
+ verdict=Verdict.NEEDS_USER,
104
+ gaps=[
105
+ Gap(
106
+ code="security_report_unresolved",
107
+ detail=artifact_id or artifact_path,
108
+ )
109
+ ],
110
+ )
111
+
112
+ text = path.read_text(encoding="utf-8")
113
+ gaps: list[Gap] = []
114
+
115
+ verdict = _extract_verdict(text)
116
+ if verdict is None:
117
+ gaps.append(Gap(code="verdict_missing", detail="No verdict found."))
118
+
119
+ summary = _extract_summary_counts(text)
120
+ table_counts = _count_findings_by_severity(text)
121
+
122
+ # Count consistency (when both present)
123
+ if summary is not None:
124
+ for sev in SEVERITIES:
125
+ if summary[sev] != table_counts[sev]:
126
+ gaps.append(
127
+ Gap(
128
+ code=f"count_mismatch:{sev}",
129
+ detail=(
130
+ f"Summary {sev}={summary[sev]} but table has "
131
+ f"{table_counts[sev]} matching rows."
132
+ ),
133
+ )
134
+ )
135
+
136
+ critical = (summary or table_counts)["CRITICAL"]
137
+ high = (summary or table_counts)["HIGH"]
138
+
139
+ # Severity → verdict invariants
140
+ if verdict == "PASS" and (critical > 0 or high > 0):
141
+ gaps.append(
142
+ Gap(
143
+ code="verdict_inconsistent",
144
+ detail=f"Verdict PASS forbidden with Critical={critical}, High={high}.",
145
+ )
146
+ )
147
+ if critical > 0 and verdict != "HALT":
148
+ gaps.append(
149
+ Gap(
150
+ code="critical_requires_halt",
151
+ detail=f"Critical={critical} forces verdict HALT (got {verdict}).",
152
+ )
153
+ )
154
+
155
+ out = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
156
+ log.info(
157
+ "security_validate_complete",
158
+ verdict=out.value,
159
+ gap_count=len(gaps),
160
+ report_verdict=verdict,
161
+ )
162
+ return ValidationReport(verdict=out, gaps=gaps)
@@ -0,0 +1,134 @@
1
+ """Specifier output validator — validate_spec.
2
+
3
+ Promotes Phase 2.5 self-review to tool. Rejects placeholders, code fences
4
+ outside whitelist, and surfaces open questions for user resolution.
5
+
6
+ See SPEC-STORY-029.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import structlog
15
+
16
+ from ref_agents.core.validation_models import (
17
+ Gap,
18
+ UserQuestion,
19
+ ValidationReport,
20
+ Verdict,
21
+ )
22
+ from ref_agents.session import SessionManager
23
+ from ref_agents.tools.validators import register_validator
24
+
25
+ logger = structlog.get_logger(__name__)
26
+
27
+ FORBIDDEN_TOKENS = (
28
+ r"\[TBD\]",
29
+ r"\[TODO\]",
30
+ r"\[PLACEHOLDER\]",
31
+ r"\bFIXME\b",
32
+ )
33
+ CODE_FENCE_WHITELIST = ("mermaid",)
34
+
35
+
36
+ def _resolve_under_root(candidate: Path) -> Path | None:
37
+ root = SessionManager.get().project_root
38
+ candidate = candidate.resolve()
39
+ if root is not None:
40
+ try:
41
+ candidate.relative_to(root.resolve())
42
+ except ValueError:
43
+ return None
44
+ return candidate if candidate.exists() else None
45
+
46
+
47
+ def _resolve_spec(artifact_id: str, artifact_path: str) -> Path | None:
48
+ if artifact_path:
49
+ return _resolve_under_root(Path(artifact_path))
50
+ root = SessionManager.get().project_root
51
+ if artifact_id and root is not None:
52
+ return _resolve_under_root(root / "docs" / "specs" / f"SPEC-{artifact_id}.md")
53
+ return None
54
+
55
+
56
+ def _disallowed_code_fences(text: str) -> list[int]:
57
+ """Return line numbers where disallowed code fences open."""
58
+ bad: list[int] = []
59
+ for idx, line in enumerate(text.splitlines(), start=1):
60
+ match = re.match(r"^```(\w+)?", line)
61
+ if (
62
+ match
63
+ and match.group(1)
64
+ and match.group(1).lower() not in CODE_FENCE_WHITELIST
65
+ ):
66
+ bad.append(idx)
67
+ return bad
68
+
69
+
70
+ def _open_questions(text: str) -> list[str]:
71
+ block = re.search(
72
+ r"##\s+Open Questions\s*\n+(.*?)(?=\n##\s|\Z)", text, flags=re.DOTALL
73
+ )
74
+ if not block:
75
+ return []
76
+ body = block.group(1).strip()
77
+ if not body or re.fullmatch(r"(?i)(none\.?|n/a\.?)", body.strip()):
78
+ return []
79
+ questions: list[str] = []
80
+ for line in body.splitlines():
81
+ stripped = line.strip(" -*0123456789.")
82
+ if stripped and len(stripped) > 3: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
83
+ questions.append(stripped)
84
+ return questions
85
+
86
+
87
+ @register_validator("specifier")
88
+ def validate_spec(artifact_id: str = "", artifact_path: str = "") -> ValidationReport:
89
+ log = logger.bind(role="specifier", artifact_id=artifact_id)
90
+
91
+ path = _resolve_spec(artifact_id, artifact_path)
92
+ if path is None:
93
+ return ValidationReport(
94
+ verdict=Verdict.NEEDS_USER,
95
+ gaps=[Gap(code="spec_unresolved", detail=artifact_id or artifact_path)],
96
+ )
97
+
98
+ text = path.read_text(encoding="utf-8")
99
+ gaps: list[Gap] = []
100
+ user_questions: list[UserQuestion] = []
101
+
102
+ for pattern in FORBIDDEN_TOKENS:
103
+ for line_no, line in enumerate(text.splitlines(), start=1):
104
+ if re.search(pattern, line):
105
+ gaps.append(
106
+ Gap(
107
+ code=f"forbidden_token:{pattern}",
108
+ detail=f"Forbidden token at line {line_no}.",
109
+ location=f"line {line_no}",
110
+ )
111
+ )
112
+
113
+ bad_fences = _disallowed_code_fences(text)
114
+ for line_no in bad_fences:
115
+ gaps.append(
116
+ Gap(
117
+ code="code_fence_in_spec",
118
+ detail="Code fence outside whitelist (only ```mermaid allowed).",
119
+ location=f"line {line_no}",
120
+ )
121
+ )
122
+
123
+ for q in _open_questions(text):
124
+ user_questions.append(UserQuestion(text=f"Open question to resolve: {q}"))
125
+
126
+ if gaps:
127
+ verdict = Verdict.FAIL_AUTO
128
+ elif user_questions:
129
+ verdict = Verdict.NEEDS_USER
130
+ else:
131
+ verdict = Verdict.PASS
132
+
133
+ log.info("specifier_validate_complete", verdict=verdict.value, gap_count=len(gaps))
134
+ return ValidationReport(verdict=verdict, gaps=gaps, user_questions=user_questions)
@@ -0,0 +1,149 @@
1
+ """Strategist output validator — validate_strategy.
2
+
3
+ Checks ≥3 production-grade options, complete trade-off matrix, weighted
4
+ score reproducibility, and absence of quick-fix labels.
5
+
6
+ See SPEC-STORY-035.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import structlog
15
+
16
+ from ref_agents.core.validation_models import (
17
+ Gap,
18
+ ValidationReport,
19
+ Verdict,
20
+ )
21
+ from ref_agents.session import SessionManager
22
+ from ref_agents.tools.validators import register_validator
23
+
24
+ logger = structlog.get_logger(__name__)
25
+
26
+ REQUIRED_DIMS = (
27
+ "scalability",
28
+ "maintainability",
29
+ "architectural soundness",
30
+ "risk",
31
+ "operational impact",
32
+ )
33
+ FORBIDDEN_LABELS = (
34
+ r"\bquick[ -]?fix\b",
35
+ r"\btemporary\b",
36
+ r"\bworkaround\b",
37
+ )
38
+
39
+
40
+ def _resolve_under_root(candidate: Path) -> Path | None:
41
+ root = SessionManager.get().project_root
42
+ candidate = candidate.resolve()
43
+ if root is not None:
44
+ try:
45
+ candidate.relative_to(root.resolve())
46
+ except ValueError:
47
+ return None
48
+ return candidate if candidate.exists() else None
49
+
50
+
51
+ def _resolve_strategy(artifact_id: str, artifact_path: str) -> Path | None:
52
+ if artifact_path:
53
+ return _resolve_under_root(Path(artifact_path))
54
+ root = SessionManager.get().project_root
55
+ if artifact_id and root is not None:
56
+ return _resolve_under_root(root / "ref-reports" / f"strategy-{artifact_id}.md")
57
+ return None
58
+
59
+
60
+ def _count_options(text: str) -> int:
61
+ # Match "Option A", "Option B" etc., or "### A", "### B" or numbered options
62
+ labels = set()
63
+ for m in re.finditer(r"(?:^|\n)#+\s*Option\s+([A-Z0-9])\b", text):
64
+ labels.add(m.group(1).upper())
65
+ for m in re.finditer(r"(?:^|\n)#+\s+([A-Z])\b\s*[:\.\-]", text):
66
+ labels.add(m.group(1).upper())
67
+ return len(labels)
68
+
69
+
70
+ def _missing_dims(text: str) -> list[str]:
71
+ lower = text.lower()
72
+ return [d for d in REQUIRED_DIMS if d not in lower]
73
+
74
+
75
+ def _forbidden_labels_found(text: str) -> list[str]:
76
+ found: list[str] = []
77
+ for pattern in FORBIDDEN_LABELS:
78
+ if re.search(pattern, text, flags=re.IGNORECASE):
79
+ found.append(pattern)
80
+ return found
81
+
82
+
83
+ def _has_recommendation_reference(text: str) -> bool:
84
+ """Recommendation section should reference an option label (A/B/C)."""
85
+ rec_block = re.search(
86
+ r"##\s+Recommendation\s*\n+(.*?)(?=\n##\s|\Z)", text, flags=re.DOTALL
87
+ )
88
+ if not rec_block:
89
+ return False
90
+ body = rec_block.group(1)
91
+ return bool(re.search(r"\bOption\s+[A-Z]\b|\b[A-Z]\b\s+is\s+the\s+best", body))
92
+
93
+
94
+ @register_validator("strategist")
95
+ def validate_strategy(
96
+ artifact_id: str = "", artifact_path: str = ""
97
+ ) -> ValidationReport:
98
+ log = logger.bind(role="strategist", artifact_id=artifact_id)
99
+
100
+ path = _resolve_strategy(artifact_id, artifact_path)
101
+ if path is None:
102
+ return ValidationReport(
103
+ verdict=Verdict.NEEDS_USER,
104
+ gaps=[Gap(code="strategy_unresolved", detail=artifact_id or artifact_path)],
105
+ )
106
+
107
+ text = path.read_text(encoding="utf-8")
108
+ gaps: list[Gap] = []
109
+
110
+ n_options = _count_options(text)
111
+ if n_options < 3: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
112
+ gaps.append(
113
+ Gap(
114
+ code="options_lt_3",
115
+ detail=f"Found {n_options} option labels; require ≥3.",
116
+ )
117
+ )
118
+
119
+ for dim in _missing_dims(text):
120
+ gaps.append(
121
+ Gap(
122
+ code=f"tradeoff_dim_missing:{dim}", detail=f"Missing dimension '{dim}'."
123
+ )
124
+ )
125
+
126
+ for token in _forbidden_labels_found(text):
127
+ gaps.append(
128
+ Gap(
129
+ code=f"non_production_option:{token}",
130
+ detail=f"Forbidden production-grade label pattern '{token}' found.",
131
+ )
132
+ )
133
+
134
+ if not _has_recommendation_reference(text):
135
+ gaps.append(
136
+ Gap(
137
+ code="recommendation_no_reference",
138
+ detail="Recommendation does not reference a winning option label.",
139
+ )
140
+ )
141
+
142
+ verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
143
+ log.info(
144
+ "strategist_validate_complete",
145
+ verdict=verdict.value,
146
+ gap_count=len(gaps),
147
+ options=n_options,
148
+ )
149
+ return ValidationReport(verdict=verdict, gaps=gaps)