ref-agents 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ref_agents/__init__.py +9 -0
- ref_agents/api_keys.json.example +8 -0
- ref_agents/auth.py +129 -0
- ref_agents/codemap/..md +62 -0
- ref_agents/codemap/CODE_MAP.md +37 -0
- ref_agents/codemap/core.md +43 -0
- ref_agents/codemap/models.md +43 -0
- ref_agents/codemap/prompts.md +40 -0
- ref_agents/codemap/security.md +45 -0
- ref_agents/codemap/tools.md +94 -0
- ref_agents/codemap/tools_browser.md +44 -0
- ref_agents/codemap/utils.md +42 -0
- ref_agents/codemap/workflow.md +42 -0
- ref_agents/config/ai_patterns.yaml +101 -0
- ref_agents/config/frameworks/angular.yaml +104 -0
- ref_agents/config/frameworks/aspnet.yaml +84 -0
- ref_agents/config/frameworks/ef_core.yaml +81 -0
- ref_agents/config/frameworks/react.yaml +111 -0
- ref_agents/config/frameworks/spring_boot.yaml +117 -0
- ref_agents/config/languages/csharp.yaml +153 -0
- ref_agents/config/languages/java.yaml +188 -0
- ref_agents/config/languages/javascript.yaml +172 -0
- ref_agents/config/languages/python.yaml +153 -0
- ref_agents/config/languages/typescript.yaml +193 -0
- ref_agents/constants.py +553 -0
- ref_agents/core/__init__.py +15 -0
- ref_agents/core/config_loader.py +160 -0
- ref_agents/core/config_models.py +167 -0
- ref_agents/core/config_parsing.py +84 -0
- ref_agents/core/language_detector.py +388 -0
- ref_agents/core/validation_models.py +66 -0
- ref_agents/core/validation_primitives.py +176 -0
- ref_agents/errors.py +34 -0
- ref_agents/license_client.py +247 -0
- ref_agents/models/__init__.py +22 -0
- ref_agents/models/gherkin.py +45 -0
- ref_agents/models/hierarchy.py +80 -0
- ref_agents/models/invest.py +59 -0
- ref_agents/models/version.py +49 -0
- ref_agents/prompts/__init__.py +9 -0
- ref_agents/prompts/start_agent.py +772 -0
- ref_agents/rules/architecture/backend_patterns.md +43 -0
- ref_agents/rules/architecture/diagramming.md +100 -0
- ref_agents/rules/architecture/frontend_patterns.md +40 -0
- ref_agents/rules/architecture/impact_analysis.md +129 -0
- ref_agents/rules/architecture/migration_strategy.md +208 -0
- ref_agents/rules/architecture/regression_protocol.md +77 -0
- ref_agents/rules/architecture/system_design.md +97 -0
- ref_agents/rules/common/codemap_standard.md +97 -0
- ref_agents/rules/common/core_protocol.md +59 -0
- ref_agents/rules/common/prompt_engineering.md +294 -0
- ref_agents/rules/development/debugging.md +32 -0
- ref_agents/rules/development/implementation.md +205 -0
- ref_agents/rules/operations/completion.md +119 -0
- ref_agents/rules/operations/cutover_protocol.md +218 -0
- ref_agents/rules/operations/discovery.md +179 -0
- ref_agents/rules/operations/fix_workflow.md +87 -0
- ref_agents/rules/operations/forensics.md +278 -0
- ref_agents/rules/operations/platform.md +263 -0
- ref_agents/rules/operations/synchronous_flow.md +25 -0
- ref_agents/rules/product/ac_validation.md +25 -0
- ref_agents/rules/product/brainstorming.md +27 -0
- ref_agents/rules/product/ref_flow.md +101 -0
- ref_agents/rules/product/requirements_std.md +114 -0
- ref_agents/rules/product/spec_writing.md +235 -0
- ref_agents/rules/product/strategy.md +96 -0
- ref_agents/rules/quality/documentation_standards.md +46 -0
- ref_agents/rules/quality/parity_testing.md +234 -0
- ref_agents/rules/quality/project_documentation.md +56 -0
- ref_agents/rules/quality/qa_lead.md +111 -0
- ref_agents/rules/quality/test_design.md +146 -0
- ref_agents/rules/quality/testing_standards.md +293 -0
- ref_agents/rules/review/pr_review.md +116 -0
- ref_agents/rules/security/security_audit.md +83 -0
- ref_agents/security/__init__.py +22 -0
- ref_agents/security/dependency_audit.py +188 -0
- ref_agents/security/file_audit.py +208 -0
- ref_agents/security/network_scan.py +179 -0
- ref_agents/security/report_generator.py +313 -0
- ref_agents/security/secret_scan.py +252 -0
- ref_agents/security/url_scan.py +240 -0
- ref_agents/security_scan.py +236 -0
- ref_agents/server.py +1586 -0
- ref_agents/session.py +100 -0
- ref_agents/tool_names.py +55 -0
- ref_agents/tools/__init__.py +8 -0
- ref_agents/tools/agents_generator.py +315 -0
- ref_agents/tools/ai_pattern_detector.py +815 -0
- ref_agents/tools/brownfield_populator.py +529 -0
- ref_agents/tools/browser/__init__.py +50 -0
- ref_agents/tools/browser/evidence_verifier.py +302 -0
- ref_agents/tools/browser/execution_logger.py +249 -0
- ref_agents/tools/browser/playwright_mcp_client.py +259 -0
- ref_agents/tools/browser/screenshot_utils.py +184 -0
- ref_agents/tools/browser/test_executor.py +537 -0
- ref_agents/tools/code_quality_scanner.py +629 -0
- ref_agents/tools/codemap/..md +93 -0
- ref_agents/tools/codemap/CODE_MAP.md +30 -0
- ref_agents/tools/codemap/browser.md +44 -0
- ref_agents/tools/codemap.py +403 -0
- ref_agents/tools/codemap_freshness.py +234 -0
- ref_agents/tools/comment_smell_scanner.py +346 -0
- ref_agents/tools/complexity.py +436 -0
- ref_agents/tools/complexity_ast.py +333 -0
- ref_agents/tools/compliance.py +246 -0
- ref_agents/tools/compliance_remediation.py +846 -0
- ref_agents/tools/context_graph.py +839 -0
- ref_agents/tools/context_manager.py +550 -0
- ref_agents/tools/context_tools.py +121 -0
- ref_agents/tools/cross_repo_linker.py +393 -0
- ref_agents/tools/dead_code_scanner.py +637 -0
- ref_agents/tools/debt_scanner.py +1092 -0
- ref_agents/tools/dependency_graph.py +272 -0
- ref_agents/tools/discovery_audit.py +372 -0
- ref_agents/tools/docs_scanner.py +600 -0
- ref_agents/tools/evaluate_gate.py +119 -0
- ref_agents/tools/external_detector.py +524 -0
- ref_agents/tools/features_generator.py +282 -0
- ref_agents/tools/flow_gap_detector.py +373 -0
- ref_agents/tools/flow_mapper.py +327 -0
- ref_agents/tools/full_suite_runner.py +740 -0
- ref_agents/tools/gherkin_parser.py +227 -0
- ref_agents/tools/guard_tools.py +139 -0
- ref_agents/tools/handoff_tools.py +282 -0
- ref_agents/tools/health_scanner.py +1211 -0
- ref_agents/tools/hierarchy_manager.py +289 -0
- ref_agents/tools/invest_scorer.py +249 -0
- ref_agents/tools/jira_confluence_export.py +306 -0
- ref_agents/tools/json_output.py +76 -0
- ref_agents/tools/migration_mapper.py +946 -0
- ref_agents/tools/migration_readiness_scanner.py +209 -0
- ref_agents/tools/pattern_learner.py +522 -0
- ref_agents/tools/report_utils.py +155 -0
- ref_agents/tools/requirements_serializer.py +225 -0
- ref_agents/tools/security_audit_tool.py +106 -0
- ref_agents/tools/sequencing_engine.py +288 -0
- ref_agents/tools/summary_generator.py +275 -0
- ref_agents/tools/symbol_resolver.py +306 -0
- ref_agents/tools/symbol_smoke_runner.py +336 -0
- ref_agents/tools/test_plan_validator.py +189 -0
- ref_agents/tools/test_smell_walker.py +902 -0
- ref_agents/tools/tier1_fixer.py +502 -0
- ref_agents/tools/validators/__init__.py +419 -0
- ref_agents/tools/validators/architect.py +268 -0
- ref_agents/tools/validators/cutover_engineer.py +167 -0
- ref_agents/tools/validators/developer.py +180 -0
- ref_agents/tools/validators/discovery.py +150 -0
- ref_agents/tools/validators/forensic_engineer.py +191 -0
- ref_agents/tools/validators/impact_architect.py +181 -0
- ref_agents/tools/validators/migration_planner.py +166 -0
- ref_agents/tools/validators/parity_tester.py +155 -0
- ref_agents/tools/validators/platform_engineer.py +134 -0
- ref_agents/tools/validators/pr_reviewer.py +129 -0
- ref_agents/tools/validators/product_manager.py +291 -0
- ref_agents/tools/validators/qa_lead.py +172 -0
- ref_agents/tools/validators/scrum_master.py +212 -0
- ref_agents/tools/validators/security_owner.py +162 -0
- ref_agents/tools/validators/specifier.py +134 -0
- ref_agents/tools/validators/strategist.py +149 -0
- ref_agents/tools/validators/tester.py +121 -0
- ref_agents/tools/version_manager.py +202 -0
- ref_agents/tools/workflow_tools.py +1549 -0
- ref_agents/utils/__init__.py +21 -0
- ref_agents/utils/git_utils.py +351 -0
- ref_agents/utils/handoff_logger.py +368 -0
- ref_agents/utils/ignore_matcher.py +270 -0
- ref_agents/workflow/__init__.py +19 -0
- ref_agents/workflow/capabilities.py +328 -0
- ref_agents/workflow/state_machine.py +708 -0
- ref_agents/workflow/transitions.py +658 -0
- ref_agents-1.0.0.dist-info/METADATA +365 -0
- ref_agents-1.0.0.dist-info/RECORD +175 -0
- ref_agents-1.0.0.dist-info/WHEEL +4 -0
- ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
- ref_agents-1.0.0.dist-info/licenses/LICENSE +115 -0
|
@@ -0,0 +1,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
|
+
)
|