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,419 @@
1
+ """Role-output validator registry and `validate` dispatcher.
2
+
3
+ Each per-role validator lives in a sibling module (e.g.,
4
+ `ref_agents.tools.validators.product_manager`) and registers itself via
5
+ the `register_validator` decorator. Foundation only — STORY-021 ships
6
+ the registry and dispatcher; per-role validators arrive in FEAT-004..007.
7
+
8
+ Contract:
9
+ validate(role, artifact_id, artifact_path) -> ValidationReport
10
+
11
+ Unknown role / missing module / unexpected exception → FAIL_AUTO with a
12
+ machine-readable `Gap.code`. Dispatcher never raises.
13
+ """
14
+
15
+ import hashlib
16
+ from collections.abc import Callable
17
+ from pathlib import Path
18
+
19
+ import structlog
20
+
21
+ from ref_agents.core.validation_models import (
22
+ Gap,
23
+ ValidationReport,
24
+ Verdict,
25
+ )
26
+
27
+ logger = structlog.get_logger(__name__)
28
+
29
+ ValidatorFn = Callable[[str, str], ValidationReport]
30
+ VALIDATOR_REGISTRY: dict[str, ValidatorFn] = {}
31
+
32
+ # v3.13.1: roles allowed to phase-close without a story id. Narrower than
33
+ # constants.STORY_EXEMPT_AGENTS — discovery + platform_engineer have
34
+ # project-root-scoped validators that must always run.
35
+ GATE_EXEMPT_WITHOUT_STORY: frozenset[str] = frozenset({"product_manager", "strategist"})
36
+
37
+ # v3.13.2: surface validator-module load failures so silent fail-open does
38
+ # not mask a broken validator. Populated by `_autoload_validators` at
39
+ # server startup; readable via `validate(role="__health__")`.
40
+ VALIDATOR_LOAD_FAILURES: list[tuple[str, str]] = []
41
+ HEALTH_ROLE = "__health__"
42
+
43
+ # Gap 9: gate-firing metrics (verdict counts per role).
44
+ GATE_METRICS: dict[str, dict[str, int]] = {}
45
+
46
+ # Gap 10: per-(role, artifact_hash) report cache (LRU-bounded).
47
+ _REPORT_CACHE: dict[tuple[str, str], ValidationReport] = {}
48
+ _CACHE_MAX = 256
49
+
50
+
51
+ def _cache_get(role: str, ahash: str) -> ValidationReport | None:
52
+ if not ahash:
53
+ return None
54
+ return _REPORT_CACHE.get((role, ahash))
55
+
56
+
57
+ def _cache_put(role: str, ahash: str, report: ValidationReport) -> None:
58
+ if not ahash:
59
+ return
60
+ if len(_REPORT_CACHE) >= _CACHE_MAX:
61
+ # Drop oldest entry (FIFO)
62
+ _REPORT_CACHE.pop(next(iter(_REPORT_CACHE)))
63
+ _REPORT_CACHE[(role, ahash)] = report
64
+
65
+
66
+ def _record_metric(role: str, verdict: str) -> None:
67
+ bucket = GATE_METRICS.setdefault(role, {})
68
+ bucket[verdict] = bucket.get(verdict, 0) + 1
69
+
70
+
71
+ def register_validator(role: str) -> Callable[[ValidatorFn], ValidatorFn]:
72
+ """Register a per-role validator function.
73
+
74
+ Usage:
75
+ @register_validator("product_manager")
76
+ def validate_story_quality(artifact_id: str, artifact_path: str) -> ValidationReport:
77
+ ...
78
+
79
+ Args:
80
+ role: Role name (must match `AVAILABLE_AGENTS` key).
81
+
82
+ Returns:
83
+ Decorator that registers the function in `VALIDATOR_REGISTRY`.
84
+ """
85
+
86
+ def decorator(fn: ValidatorFn) -> ValidatorFn:
87
+ VALIDATOR_REGISTRY[role] = fn
88
+ return fn
89
+
90
+ return decorator
91
+
92
+
93
+ def _artifact_hash(artifact_path: str) -> str:
94
+ """Compute sha256 of artifact bytes. Empty path → ''.
95
+
96
+ Args:
97
+ artifact_path: Path string; may be empty.
98
+
99
+ Returns:
100
+ Hex digest, or "" when path is empty or unreadable.
101
+ """
102
+ if not artifact_path:
103
+ return ""
104
+ try:
105
+ return hashlib.sha256(Path(artifact_path).read_bytes()).hexdigest()
106
+ except OSError:
107
+ return ""
108
+
109
+
110
+ def validate(
111
+ role: str, artifact_id: str = "", artifact_path: str = ""
112
+ ) -> ValidationReport:
113
+ """Dispatch a role-output validation pass.
114
+
115
+ Args:
116
+ role: Role name; routes to `VALIDATOR_REGISTRY[role]`.
117
+ artifact_id: Story / artifact identifier (e.g., "STORY-021").
118
+ artifact_path: Optional explicit path to artifact on disk.
119
+
120
+ Returns:
121
+ ValidationReport. Unknown role / load failure / unexpected
122
+ exception → FAIL_AUTO with a structured Gap. Never raises.
123
+ """
124
+ log = logger.bind(role=role, artifact_id=artifact_id)
125
+
126
+ # v3.13.2: surface registry health for operators / CI.
127
+ if role == HEALTH_ROLE:
128
+ return _health_report()
129
+
130
+ fn = VALIDATOR_REGISTRY.get(role)
131
+ if fn is None:
132
+ log.info("validate_unknown_role")
133
+ report = ValidationReport(
134
+ verdict=Verdict.FAIL_AUTO,
135
+ gaps=[
136
+ Gap(
137
+ code="unknown_role",
138
+ detail=f"No validator registered for role '{role}'.",
139
+ )
140
+ ],
141
+ )
142
+ _record_metric(role, report.verdict.value)
143
+ return report
144
+
145
+ ahash = _artifact_hash(artifact_path)
146
+ cached = _cache_get(role, ahash)
147
+ if cached is not None:
148
+ log.info("validate_cache_hit", verdict=cached.verdict.value)
149
+ _record_metric(role, cached.verdict.value)
150
+ return cached
151
+
152
+ try:
153
+ report = fn(artifact_id, artifact_path)
154
+ except Exception as exc: # noqa: BLE001 - dispatcher must never raise
155
+ log.exception("validator_exception", error=str(exc))
156
+ report = ValidationReport(
157
+ verdict=Verdict.FAIL_AUTO,
158
+ gaps=[
159
+ Gap(
160
+ code="validator_exception",
161
+ detail=f"{type(exc).__name__}: {exc}",
162
+ )
163
+ ],
164
+ )
165
+ _record_metric(role, report.verdict.value)
166
+ return report
167
+
168
+ _cache_put(role, ahash, report)
169
+ _record_metric(role, report.verdict.value)
170
+ log.info(
171
+ "validate_complete",
172
+ verdict=report.verdict.value,
173
+ gap_count=len(report.gaps),
174
+ )
175
+ return report
176
+
177
+
178
+ def get_gate_metrics() -> dict[str, dict[str, int]]:
179
+ """Return per-role verdict counts since process start (Gap 9)."""
180
+ return {role: dict(counts) for role, counts in GATE_METRICS.items()}
181
+
182
+
183
+ def clear_validator_cache() -> None:
184
+ """Drop the per-(role, hash) cache. Used by tests and live re-runs."""
185
+ _REPORT_CACHE.clear()
186
+
187
+
188
+ def phase_gate(
189
+ role: str, story_id: str, artifact_path: str = ""
190
+ ) -> tuple[bool, str, ValidationReport | None]:
191
+ """Centralised G4 phase-close gate used by log() and state_machine.
192
+
193
+ Encapsulates: story-exempt skip (Gap 5), per-story lock (Gap 7), retry
194
+ counter + escalation (Gap 2), structured NEEDS_USER payload (Gap 3),
195
+ and audit-log emission on block (Gap 6).
196
+
197
+ Args:
198
+ role: Phase / role name.
199
+ story_id: Active story (may be empty for story-exempt agents).
200
+ artifact_path: Optional explicit artifact path; resolves through
201
+ validator if blank.
202
+
203
+ Returns:
204
+ (allowed, message, report) where:
205
+ allowed=True → caller proceeds (message empty)
206
+ allowed=False → caller returns message verbatim to user.
207
+ `report` is the underlying ValidationReport when one was produced.
208
+ """
209
+ from ref_agents.session import SessionManager
210
+ from ref_agents.utils import handoff_logger as _hl
211
+
212
+ # Gap 5 (narrowed in v3.13.1): only PM + strategist legitimately need
213
+ # to close phases without a story id (they may be creating epics/features
214
+ # or producing strategy docs that have no story-scoped artifact yet).
215
+ # Discovery + platform_engineer are story-exempt at the agent layer but
216
+ # their validators check project-root-scoped artifacts (codemap/,
217
+ # health_report.json) and MUST run on every phase close.
218
+ if role in GATE_EXEMPT_WITHOUT_STORY and not story_id:
219
+ return True, "", None
220
+
221
+ # Skip silently when role has no registered validator.
222
+ if role not in VALIDATOR_REGISTRY:
223
+ return True, "", None
224
+
225
+ session = SessionManager.get()
226
+
227
+ # Gap 7: per-story lock prevents concurrent G4 runs and audit dupes.
228
+ lock_key = story_id or f"__role:{role}"
229
+ with session.story_lock(lock_key):
230
+ report = validate(role=role, artifact_id=story_id, artifact_path=artifact_path)
231
+
232
+ if report.verdict == Verdict.PASS:
233
+ return True, "", report
234
+
235
+ # Pass-through when validator cannot resolve its input artifact.
236
+ # This avoids breaking flows where the artifact lives outside the
237
+ # validator's default search path (e.g., unit-test fixtures, partial
238
+ # rollouts). Callers can still see the gap in `report` if they want
239
+ # to enforce stricter behavior themselves.
240
+ if (
241
+ report.verdict == Verdict.NEEDS_USER
242
+ and report.gaps
243
+ and all(
244
+ g.code.endswith("_unresolved") or g.code == "no_project_root"
245
+ for g in report.gaps
246
+ )
247
+ ):
248
+ return True, "", report
249
+
250
+ # Gap 11: validator_version is on the report; surface in audit log.
251
+ # Gap 6: write handoff_log escalation entry before blocking.
252
+ try:
253
+ _hl.log_escalation(
254
+ level="L1",
255
+ from_agent=role,
256
+ to_agents=["user"],
257
+ reason=(
258
+ f"validator_blocked verdict={report.verdict.value} "
259
+ f"gaps={[g.code for g in report.gaps][:5]}"
260
+ ),
261
+ story_id=story_id or None,
262
+ )
263
+ except Exception as exc: # noqa: BLE001 — audit best-effort
264
+ logger.warning("gate_audit_log_failed", error=str(exc))
265
+
266
+ if report.verdict == Verdict.HALT:
267
+ session.set_halt(story_id or role)
268
+ return (
269
+ False,
270
+ (
271
+ f"🛑 Phase close blocked (HALT) for role={role}: "
272
+ f"{report.halt_reason or 'validator HALT'}\n"
273
+ f"Clear via session.clear_halt({story_id!r} or role)."
274
+ ),
275
+ report,
276
+ )
277
+
278
+ # v3.13.2: no supervision escape valve. FAIL_AUTO/NEEDS_USER block
279
+ # unconditionally; standards hold.
280
+
281
+ # Gap 2: retry counter + auto-escalation after N=2 failures.
282
+ ahash = _artifact_hash(artifact_path)
283
+ count = session.increment_retry_count(story_id or role, role, ahash)
284
+ escalate = count >= 2 # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
285
+
286
+ gap_lines = "\n".join(f" - [{g.code}] {g.detail}" for g in report.gaps[:10])
287
+ q_lines = "\n".join(
288
+ f" ?[{q.text}]" + (f" options={q.options}" if q.options else "")
289
+ for q in report.user_questions[:5]
290
+ )
291
+
292
+ # Gap 3: structured marker so caller agents can route NEEDS_USER
293
+ # to AskUserQuestion. The "USER_QUESTIONS:" sentinel is documented
294
+ # in the LOGGING_PROTOCOL prompt.
295
+ marker = (
296
+ "\nUSER_QUESTIONS_REQUIRED: agent MUST surface questions via "
297
+ "AskUserQuestion before retry.\n"
298
+ if report.verdict == Verdict.NEEDS_USER and report.user_questions
299
+ else ""
300
+ )
301
+ escalation_note = (
302
+ f"\n↗️ Retry threshold ({count}/2) reached — escalate to user."
303
+ if escalate
304
+ else ""
305
+ )
306
+
307
+ return (
308
+ False,
309
+ (
310
+ f"❌ Phase close blocked by {role} validator "
311
+ f"(verdict={report.verdict.value}, version={report.validator_version}).\n"
312
+ f"{gap_lines}\n{q_lines}{marker}{escalation_note}\n\n"
313
+ f"Fix gaps then re-call."
314
+ ),
315
+ report,
316
+ )
317
+
318
+
319
+ def artifact_hash(artifact_path: str) -> str:
320
+ """Public alias for `_artifact_hash` used by state-machine wiring."""
321
+ return _artifact_hash(artifact_path)
322
+
323
+
324
+ def _autoload_validators() -> None:
325
+ """Import all role validator modules so VALIDATOR_REGISTRY is populated.
326
+
327
+ Per-validator modules self-register via `@register_validator(role)` at
328
+ import time. Import failures are logged and ignored so server startup
329
+ survives a single broken validator.
330
+ """
331
+ import importlib
332
+
333
+ role_modules = (
334
+ "product_manager",
335
+ "architect",
336
+ "qa_lead",
337
+ "scrum_master",
338
+ "pr_reviewer",
339
+ "developer",
340
+ "impact_architect",
341
+ "specifier",
342
+ "tester",
343
+ "security_owner",
344
+ "discovery",
345
+ "platform_engineer",
346
+ "forensic_engineer",
347
+ "strategist",
348
+ "migration_planner",
349
+ "parity_tester",
350
+ "cutover_engineer",
351
+ )
352
+ for mod in role_modules:
353
+ try:
354
+ importlib.import_module(f"ref_agents.tools.validators.{mod}")
355
+ except Exception as exc: # noqa: BLE001 - one broken validator must not kill server
356
+ VALIDATOR_LOAD_FAILURES.append((mod, f"{type(exc).__name__}: {exc}"))
357
+ logger.warning("validator_module_load_failed", module=mod, error=str(exc))
358
+
359
+
360
+ _EXPECTED_ROLES: tuple[str, ...] = (
361
+ "product_manager",
362
+ "architect",
363
+ "qa_lead",
364
+ "scrum_master",
365
+ "pr_reviewer",
366
+ "developer",
367
+ "impact_architect",
368
+ "specifier",
369
+ "tester",
370
+ "security_owner",
371
+ "discovery",
372
+ "platform_engineer",
373
+ "forensic_engineer",
374
+ "strategist",
375
+ "migration_planner",
376
+ "parity_tester",
377
+ "cutover_engineer",
378
+ )
379
+
380
+
381
+ def _health_report() -> ValidationReport:
382
+ """Return a ValidationReport describing validator-registry health.
383
+
384
+ Used by `validate(role="__health__")`. Verdict is PASS when every
385
+ expected validator loaded; FAIL_AUTO otherwise with one Gap per
386
+ failed or missing role.
387
+ """
388
+ registered = set(VALIDATOR_REGISTRY.keys())
389
+ expected = set(_EXPECTED_ROLES)
390
+ missing = sorted(expected - registered)
391
+
392
+ gaps: list[Gap] = []
393
+ for role, error in VALIDATOR_LOAD_FAILURES:
394
+ gaps.append(
395
+ Gap(
396
+ code=f"validator_load_failed:{role}",
397
+ detail=error,
398
+ location=role,
399
+ )
400
+ )
401
+ for role in missing:
402
+ if any(role in entry[0] for entry in VALIDATOR_LOAD_FAILURES):
403
+ continue
404
+ gaps.append(
405
+ Gap(
406
+ code=f"validator_missing:{role}",
407
+ detail=f"Expected validator '{role}' not registered.",
408
+ location=role,
409
+ )
410
+ )
411
+
412
+ verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
413
+ return ValidationReport(
414
+ verdict=verdict,
415
+ gaps=gaps,
416
+ )
417
+
418
+
419
+ _autoload_validators()
@@ -0,0 +1,268 @@
1
+ """Architect output validator — validate_design_artifact.
2
+
3
+ Reads DESIGN-{id}.md, checks mermaid presence, decision log completeness,
4
+ component-codemap bijection, regression risk, and NFR coverage.
5
+ Registered under role "architect".
6
+
7
+ See SPEC-STORY-023 / DESIGN-STORY-023.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ import structlog
16
+
17
+ from ref_agents.core.validation_models import (
18
+ Gap,
19
+ UserQuestion,
20
+ ValidationReport,
21
+ Verdict,
22
+ )
23
+ from ref_agents.session import SessionManager
24
+ from ref_agents.tools.validators import register_validator
25
+
26
+ logger = structlog.get_logger(__name__)
27
+
28
+ NFR_KEYWORDS = (
29
+ "latency",
30
+ "scale",
31
+ "throughput",
32
+ "security",
33
+ "observability",
34
+ "performance",
35
+ )
36
+ RISK_LEVELS = ("LOW", "MEDIUM", "HIGH", "CRITICAL")
37
+ DECISION_REQUIRED_FIELDS = ("decision", "options", "chosen", "rationale")
38
+
39
+
40
+ def _resolve_design_path(artifact_id: str, artifact_path: str) -> Path | None:
41
+ """Resolve design file path under project root with traversal guard."""
42
+ root = SessionManager.get().project_root
43
+ if artifact_path:
44
+ candidate = Path(artifact_path).resolve()
45
+ elif artifact_id and root is not None:
46
+ candidate = (root / "docs" / "design" / f"DESIGN-{artifact_id}.md").resolve()
47
+ else:
48
+ return None
49
+
50
+ if root is not None:
51
+ try:
52
+ candidate.relative_to(root.resolve())
53
+ except ValueError:
54
+ return None
55
+
56
+ if not candidate.exists():
57
+ return None
58
+ return candidate
59
+
60
+
61
+ def _has_mermaid(text: str) -> bool:
62
+ return bool(re.search(r"^```mermaid\b", text, flags=re.MULTILINE))
63
+
64
+
65
+ def _decision_log_gaps(text: str) -> list[str]:
66
+ """Return list of decision entries missing required fields."""
67
+ block_match = re.search(
68
+ r"##\s+Decision Log\s*\n+(.*?)(?=\n##\s|\Z)", text, flags=re.DOTALL
69
+ )
70
+ if not block_match:
71
+ return ["__missing_section__"]
72
+
73
+ block = "\n" + block_match.group(1)
74
+ entries = re.split(r"\n###\s+", block)
75
+ issues: list[str] = []
76
+ for raw in entries[1:]: # skip preamble before first ###
77
+ name = raw.splitlines()[0].strip() if raw.strip() else "?"
78
+ body_lower = raw.lower()
79
+ for field in DECISION_REQUIRED_FIELDS:
80
+ if field not in body_lower:
81
+ issues.append(f"{name}:{field}")
82
+ break
83
+ return issues
84
+
85
+
86
+ def _component_names_from_design(text: str) -> list[str]:
87
+ """Extract component identifiers from Component Registry table (col 1)."""
88
+ block_match = re.search(
89
+ r"##\s+Component(?:\s+Registry)?\s*\n+(.*?)(?=\n##\s|\Z)",
90
+ text,
91
+ flags=re.DOTALL,
92
+ )
93
+ if not block_match:
94
+ return []
95
+
96
+ block = block_match.group(1)
97
+ names: list[str] = []
98
+ for line in block.splitlines():
99
+ if not line.startswith("|"):
100
+ continue
101
+ if re.match(r"\|\s*-+", line): # separator row
102
+ continue
103
+ cells = [c.strip() for c in line.strip("|").split("|")]
104
+ if not cells:
105
+ continue
106
+ first = cells[0]
107
+ if first.lower() in ("component", ""):
108
+ continue
109
+ first = re.sub(r"^[`*_]+|[`*_]+$", "", first)
110
+ if first:
111
+ names.append(first)
112
+ return names
113
+
114
+
115
+ def _codemap_corpus(project_root: Path) -> str:
116
+ """Read all codemap files into a single search corpus."""
117
+ codemap_root = project_root / "codemap"
118
+ if not codemap_root.exists():
119
+ return ""
120
+ parts: list[str] = []
121
+ for md in codemap_root.rglob("*.md"):
122
+ try:
123
+ parts.append(md.read_text(encoding="utf-8"))
124
+ except OSError:
125
+ continue
126
+ return "\n".join(parts)
127
+
128
+
129
+ def _component_unmapped(names: list[str], corpus: str) -> list[str]:
130
+ return [n for n in names if n and n not in corpus]
131
+
132
+
133
+ def _risk_level(text: str) -> str | None:
134
+ block_match = re.search(
135
+ r"##\s+Regression Risk(?:\s+Assessment)?\s*\n+(.*?)(?=\n##\s|\Z)",
136
+ text,
137
+ flags=re.DOTALL,
138
+ )
139
+ if not block_match:
140
+ return None
141
+ body = block_match.group(1).upper()
142
+ for level in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
143
+ if re.search(rf"\bRISK:?\s*[*_]*{level}\b", body) or re.search(
144
+ rf"\b{level}\b", body
145
+ ):
146
+ return level
147
+ return None
148
+
149
+
150
+ def _nfr_mentioned(text: str) -> bool:
151
+ lower = text.lower()
152
+ return any(kw in lower for kw in NFR_KEYWORDS)
153
+
154
+
155
+ @register_validator("architect")
156
+ def validate_design_artifact(
157
+ artifact_id: str = "", artifact_path: str = ""
158
+ ) -> ValidationReport:
159
+ """Validate an Architect design artifact.
160
+
161
+ Args:
162
+ artifact_id: Story identifier; resolves to
163
+ `docs/design/DESIGN-<artifact_id>.md` when path not supplied.
164
+ artifact_path: Explicit design file path.
165
+
166
+ Returns:
167
+ ValidationReport per SPEC-STORY-023.
168
+ """
169
+ log = logger.bind(role="architect", artifact_id=artifact_id)
170
+
171
+ path = _resolve_design_path(artifact_id, artifact_path)
172
+ if path is None:
173
+ return ValidationReport(
174
+ verdict=Verdict.NEEDS_USER,
175
+ gaps=[
176
+ Gap(code="design_path_unresolved", detail=artifact_id or artifact_path)
177
+ ],
178
+ user_questions=[
179
+ UserQuestion(
180
+ text=f"Provide a path to the design file for {artifact_id or '?'}."
181
+ )
182
+ ],
183
+ )
184
+
185
+ text = path.read_text(encoding="utf-8")
186
+ gaps: list[Gap] = []
187
+ user_questions: list[UserQuestion] = []
188
+
189
+ # FR-1: Mermaid
190
+ if not _has_mermaid(text):
191
+ gaps.append(Gap(code="no_diagram", detail="No ```mermaid block found."))
192
+
193
+ # FR-2: Decision Log
194
+ decision_issues = _decision_log_gaps(text)
195
+ if decision_issues == ["__missing_section__"]:
196
+ gaps.append(
197
+ Gap(code="decision_log_missing", detail="No '## Decision Log' section.")
198
+ )
199
+ else:
200
+ for issue in decision_issues:
201
+ gaps.append(
202
+ Gap(
203
+ code=f"decision_log_incomplete:{issue}",
204
+ detail="Required field missing from decision entry.",
205
+ )
206
+ )
207
+
208
+ # FR-3: Component Registry bijection vs codemap
209
+ root = SessionManager.get().project_root
210
+ component_names = _component_names_from_design(text)
211
+ if root is not None and component_names:
212
+ corpus = _codemap_corpus(root)
213
+ unmapped = _component_unmapped(component_names, corpus)
214
+ for name in unmapped:
215
+ gaps.append(
216
+ Gap(
217
+ code=f"component_unmapped:{name}",
218
+ detail="Component not found in any codemap file.",
219
+ location=name,
220
+ )
221
+ )
222
+
223
+ # FR-4: Regression Risk
224
+ risk = _risk_level(text)
225
+ if risk is None:
226
+ gaps.append(
227
+ Gap(code="risk_missing", detail="No Regression Risk section/level.")
228
+ )
229
+ elif risk in ("HIGH", "CRITICAL"):
230
+ user_questions.append(
231
+ UserQuestion(
232
+ text=(
233
+ f"Regression risk = {risk}. Approve proceeding, request "
234
+ "feature-flag wrapper, or send back for redesign?"
235
+ ),
236
+ options=["proceed", "feature_flag", "redesign"],
237
+ )
238
+ )
239
+
240
+ # FR-5: NFR coverage
241
+ if not _nfr_mentioned(text):
242
+ user_questions.append(
243
+ UserQuestion(
244
+ text=(
245
+ "Design does not mention any NFR (latency / scale / throughput / "
246
+ "security / observability / performance). Confirm which apply."
247
+ )
248
+ )
249
+ )
250
+
251
+ if gaps:
252
+ verdict = Verdict.FAIL_AUTO
253
+ elif user_questions:
254
+ verdict = Verdict.NEEDS_USER
255
+ else:
256
+ verdict = Verdict.PASS
257
+
258
+ log.info(
259
+ "architect_validate_complete",
260
+ verdict=verdict.value,
261
+ gap_count=len(gaps),
262
+ risk=risk,
263
+ )
264
+ return ValidationReport(
265
+ verdict=verdict,
266
+ gaps=gaps,
267
+ user_questions=user_questions,
268
+ )