diff --git a/devtools/test-skill/README.md b/devtools/test-skill/README.md
new file mode 100644
index 00000000..3db1f411
--- /dev/null
+++ b/devtools/test-skill/README.md
@@ -0,0 +1,158 @@
+# forge test-skill — Local Skill Testing
+
+Test Forge skills locally without Jira, GitHub, or the hosted beta.
+Uses Forge's own deepagents + FilesystemBackend — the same code path
+as hosted Forge planning agents.
+
+## Quick Start
+
+```bash
+# Via forge CLI
+forge test-skill run \
+ --skill generate-prd \
+ --skill-dir skills/myproject/generate-prd \
+ --project myproject \
+ --input test-case.yaml \
+ --output output/
+
+# Or directly
+python3 devtools/test-skill/run.py \
+ --skill generate-prd \
+ --skill-dir skills/myproject/generate-prd \
+ --project myproject \
+ --input test-case.yaml \
+ --output output/
+```
+
+## What It Reproduces
+
+Uses Forge's own deepagents library — the same agent, backend, and
+middleware as hosted Forge. Skills are discovered via SkillsMiddleware,
+not manually injected.
+
+| Component | How |
+|-----------|-----|
+| Agent | `create_deep_agent()` — same as `ForgeAgent._create_agent_async()` |
+| Backend | `FilesystemBackend(virtual_mode=True)` — file tools match production |
+| Skills | SkillsMiddleware auto-discovers from `/opt/forge/skills/{project}/` |
+| System prompt | `forge.prompts.load_prompt("system")` — same templates as production |
+| User message | `load_prompt("{skill-name}")` — same per-skill templates |
+| Model | Configurable in `config.yaml` or `--model` flag |
+| References | Injected via `--references` (same format as `forge.references` property) |
+
+**Not simulated:** shell/command execution (`LocalShellBackend`), MCP tools,
+Jira/GitHub integrations, conversation summarization thresholds.
+
+## Input Format
+
+```yaml
+jira_key: PROJ-1234
+title: "Feature Title"
+prompt: |
+ # PROJ-1234: Feature Title
+
+ ## Description
+ The full Jira feature description goes here.
+ Copy it from Jira — no live access needed at runtime.
+```
+
+If a `gold-prd.md` file exists alongside `input.yaml`, it's automatically
+appended to the prompt as an approved PRD (useful for generate-spec).
+
+## CLI Reference
+
+### forge test-skill run
+
+| Flag | Description |
+|------|-------------|
+| `--skill NAME` | Skill name, e.g., `generate-prd` (required) |
+| `--skill-dir PATH` | Path to skill directory containing SKILL.md (required) |
+| `--input FILE` | Single input.yaml test case |
+| `--dataset DIR` | Directory of test cases (runs all) |
+| `--output DIR` | Output directory (required) |
+| `--project NAME` | Project name for skill path (overrides config.yaml) |
+| `--model MODEL` | Override model (default: `claude-opus-4-6`) |
+| `--references FILE` | JSON file with reference docs (same format as `forge.references`) |
+| `--repos DIR [DIR...]` | Local repo directories to copy into workspace |
+| `--mlflow URI` | MLflow tracking URI for auto-tracing |
+| `--mlflow-experiment NAME` | MLflow experiment name (default: `forge-skill-eval`) |
+
+### forge test-skill eval
+
+| Flag | Description |
+|------|-------------|
+| `--criteria FILE` | Path to criteria YAML (required) |
+| `--generated FILE` | Path to generated artifact |
+| `--gold FILE` | Path to gold standard artifact |
+| `--dataset DIR` | Dataset directory (batch mode) |
+| `--results-dir DIR` | Runner output directory (batch mode) |
+| `--output DIR` | Output directory for reports (required) |
+| `--mlflow URI` | MLflow tracking URI |
+
+## Evaluator
+
+Judges generated artifacts against gold standards using an LLM judge
+(Sonnet by default). Criteria are defined per skill in YAML:
+
+```yaml
+# evaluators/criteria/generate-prd.yaml
+skill: generate-prd
+judge_model: claude-sonnet-4-6
+gold_standard_file: gold-prd.md
+
+criteria:
+ - id: scope-accuracy
+ name: "Scope Accuracy"
+ weight: critical
+ prompt: |
+ Compare In Scope and Out of Scope items against the gold standard...
+```
+
+Reports: terminal (colored), JSON (`results.json`), HTML (`report.html`).
+
+## Adding a New Skill
+
+1. Verify the prompt template exists at `src/forge/prompts/v1/{skill-name}.md`
+2. Create `input.yaml` with pre-fetched Jira content
+3. Point `--skill-dir` to the skill directory (must contain `SKILL.md`)
+4. Run it — output files and `trace.json` go to `--output`
+5. Optionally create `evaluators/criteria/{skill-name}.yaml` for automated grading
+
+## References
+
+To inject reference documentation (matching `forge.references` project property):
+
+```bash
+# Export from Forge config
+forge get-config MYPROJECT --property forge.references > refs.json
+
+# Use in test runner
+forge test-skill run \
+ --skill generate-prd \
+ --skill-dir skills/myproject/generate-prd \
+ --project myproject \
+ --references refs.json \
+ --input test-case.yaml \
+ --output output/
+```
+
+## Configuration
+
+`devtools/test-skill/config.yaml`:
+
+```yaml
+model: claude-opus-4-6
+max_tokens: 16384
+project: default # override with --project
+```
+
+## Requirements
+
+Requires `deepagents`, `langchain-anthropic`, and `langgraph` (all in
+Forge's `pyproject.toml`). Uses Vertex AI when `ANTHROPIC_VERTEX_PROJECT_ID`
+is set, otherwise direct Anthropic API.
+
+## Related
+
+- PR: https://github.com/forge-sdlc/forge/pull/297
+- Issue: https://github.com/forge-sdlc/forge/issues/296
diff --git a/devtools/test-skill/config.yaml b/devtools/test-skill/config.yaml
new file mode 100644
index 00000000..01cd548b
--- /dev/null
+++ b/devtools/test-skill/config.yaml
@@ -0,0 +1,11 @@
+# Model used for skill execution (not for judging — judge model is in criteria YAML)
+model: claude-opus-4-6
+max_tokens: 16384
+
+# Project name — determines skill path: /opt/forge/skills/{project}/{skill-name}/
+# Override per project or use --project CLI flag (e.g., myproject)
+project: default
+
+# Paths inside the temp workspace (match Forge's container layout)
+skill_base_path: /opt/forge/skills
+workspace_path: /home/user
diff --git a/devtools/test-skill/evaluate.py b/devtools/test-skill/evaluate.py
new file mode 100644
index 00000000..93b3c994
--- /dev/null
+++ b/devtools/test-skill/evaluate.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""
+Forge skill output evaluator — judges generated artifacts against gold standards.
+
+Usage:
+ # Evaluate a single generated artifact against its gold standard
+ python3 devtools/test-skill/evaluate.py \
+ --criteria devtools/test-skill/evaluators/criteria/generate-prd.yaml \
+ --generated output/enhancements/OSAC-1234/prd.md \
+ --gold gold-prd.md \
+ --output output/eval/
+
+ # Evaluate after a runner batch (all cases in a dataset)
+ python3 devtools/test-skill/evaluate.py \
+ --criteria devtools/test-skill/evaluators/criteria/generate-prd.yaml \
+ --dataset eval/dataset/cases/ \
+ --results-dir output/ \
+ --output output/eval/
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+SCRIPT_DIR = Path(__file__).parent
+sys.path.insert(0, str(SCRIPT_DIR))
+
+from evaluators.judge import evaluate, load_criteria
+from evaluators.reports import print_terminal, save_json, save_html
+
+try:
+ import mlflow
+ import mlflow.anthropic
+
+ HAS_MLFLOW = True
+except ImportError:
+ HAS_MLFLOW = False
+
+
+def find_generated_file(output_dir: Path, criteria_config: dict) -> Path | None:
+ for pattern in criteria_config.get("generated_file_patterns", []):
+ matches = list(output_dir.glob(pattern))
+ if matches:
+ return matches[0]
+ for f in output_dir.rglob("prd.md"):
+ return f
+ return None
+
+
+_mlflow_enabled = False
+
+
+def run_single(
+ criteria_path: Path,
+ generated_path: Path,
+ gold_path: Path,
+ output_dir: Path,
+):
+ case_name = generated_path.parent.name or generated_path.stem
+
+ print(f"Evaluating: {generated_path.name}")
+ print(f" Generated: {generated_path}")
+ print(f" Gold: {gold_path}")
+
+ if HAS_MLFLOW and _mlflow_enabled:
+ with mlflow.start_run(run_name=f"eval — {case_name}"):
+ report = evaluate(criteria_path, generated_path, gold_path)
+
+ print_terminal(report)
+ save_json(report, output_dir)
+ save_html(report, output_dir)
+
+ mlflow.set_tag("case", case_name)
+ mlflow.set_tag("type", "evaluation")
+ mlflow.set_tag("skill", report.skill)
+ mlflow.set_tag("grade", report.grade)
+
+ mlflow.log_metric("total_score", report.total_score)
+ mlflow.log_metric("max_score", report.max_score)
+ mlflow.log_metric("score_pct", round(report.total_score / report.max_score * 100, 1))
+ mlflow.log_metric("criteria_passed", report.total_passed)
+ mlflow.log_metric("criteria_total", report.total_criteria)
+ mlflow.log_metric("critical_failures", len(report.critical_failures))
+
+ for r in report.results:
+ mlflow.log_metric(f"c_{r.id}", r.score)
+
+ try:
+ results_json = output_dir / "results.json"
+ if results_json.exists():
+ mlflow.log_artifact(str(results_json), "eval")
+ except Exception:
+ pass
+
+ print(f" MLflow: logged eval for {case_name}")
+ else:
+ report = evaluate(criteria_path, generated_path, gold_path)
+ print_terminal(report)
+ save_json(report, output_dir)
+ save_html(report, output_dir)
+
+ return report
+
+
+def run_batch(
+ criteria_path: Path,
+ dataset_dir: Path,
+ results_dir: Path,
+ output_dir: Path,
+):
+ config = load_criteria(criteria_path)
+ reports = []
+
+ for case_dir in sorted(dataset_dir.iterdir()):
+ if not case_dir.is_dir():
+ continue
+
+ gold_file = case_dir / config.get("gold_standard_file", "gold-prd.md")
+ if not gold_file.exists():
+ print(f"Skipping {case_dir.name}: no gold standard")
+ continue
+
+ case_results = results_dir / case_dir.name
+ if not case_results.exists():
+ print(f"Skipping {case_dir.name}: no run results at {case_results}")
+ continue
+
+ generated = find_generated_file(case_results, config)
+ if not generated:
+ print(f"Skipping {case_dir.name}: no generated artifact found")
+ continue
+
+ case_output = output_dir / case_dir.name
+ report = run_single(criteria_path, generated, gold_file, case_output)
+ reports.append((case_dir.name, report))
+
+ if reports:
+ print(f"\n{'='*55}")
+ print(f"Batch Summary: {len(reports)} cases evaluated")
+ print(f"{'='*55}")
+ for name, r in reports:
+ status = "PASS" if r.overall_pass else "FAIL"
+ print(f" {name:<30} {r.grade} {status} {r.total_passed}/{r.total_criteria} score {r.total_score}/{r.max_score}")
+
+
+def main():
+ global _mlflow_enabled
+
+ parser = argparse.ArgumentParser(description="Forge skill output evaluator")
+ parser.add_argument("--criteria", required=True, help="Path to criteria YAML file")
+ parser.add_argument("--generated", help="Path to generated artifact")
+ parser.add_argument("--gold", help="Path to gold standard artifact")
+ parser.add_argument("--dataset", help="Path to dataset directory (batch mode)")
+ parser.add_argument("--results-dir", help="Path to runner output directory (batch mode)")
+ parser.add_argument("--output", required=True, help="Output directory for reports")
+ parser.add_argument(
+ "--mlflow",
+ metavar="URI",
+ help="MLflow tracking URI (e.g., http://host:5000). Logs eval scores as MLflow runs.",
+ )
+ parser.add_argument(
+ "--mlflow-experiment",
+ default="forge-skill-eval",
+ help="MLflow experiment name (default: forge-skill-eval)",
+ )
+ args = parser.parse_args()
+
+ if args.mlflow and HAS_MLFLOW:
+ import logging
+ logging.getLogger("mlflow.tracing.export").setLevel(logging.ERROR)
+ mlflow.set_tracking_uri(args.mlflow)
+ mlflow.set_experiment(args.mlflow_experiment)
+ mlflow.anthropic.autolog()
+ _mlflow_enabled = True
+ print(f"MLflow: tracking to {args.mlflow}, experiment '{args.mlflow_experiment}'")
+
+ criteria_path = Path(args.criteria)
+ output_dir = Path(args.output)
+
+ if args.generated and args.gold:
+ run_single(criteria_path, Path(args.generated), Path(args.gold), output_dir)
+ elif args.dataset and args.results_dir:
+ run_batch(criteria_path, Path(args.dataset), Path(args.results_dir), output_dir)
+ else:
+ print("Error: provide either --generated + --gold, or --dataset + --results-dir")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/devtools/test-skill/evaluators/__init__.py b/devtools/test-skill/evaluators/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/devtools/test-skill/evaluators/criteria/generate-prd.yaml b/devtools/test-skill/evaluators/criteria/generate-prd.yaml
new file mode 100644
index 00000000..c0ce0398
--- /dev/null
+++ b/devtools/test-skill/evaluators/criteria/generate-prd.yaml
@@ -0,0 +1,90 @@
+skill: generate-prd
+description: "PRD generation quality criteria based on 7 gaps identified in the Forge experiment"
+
+gold_standard_file: gold-prd.md
+generated_file_patterns:
+ - "enhancements/*/prd.md"
+ - ".artifacts/prd/*/03-prd.md"
+
+judge_model: claude-sonnet-4-6
+
+criteria:
+ - id: persona-coverage
+ name: "Persona Coverage"
+ weight: critical
+ prompt: |
+ Check if all 4 OSAC personas are addressed (Cloud Provider Admin,
+ Cloud Infrastructure Admin, Tenant Admin, Tenant User). Each must
+ have user stories or an explicit "Not affected" note.
+ Pay special attention to Cloud Infrastructure Admin — if the feature
+ automates a process they currently perform, they MUST have stories.
+
+ - id: persona-alignment
+ name: "Persona-Story Alignment"
+ weight: critical
+ prompt: |
+ For each user story, verify the capability matches the persona's role:
+ - Infrastructure ops (sanitization, hardware lifecycle) → Cloud Infrastructure Admin
+ - Tenant management (quotas, catalogs, cross-tenant visibility) → Cloud Provider Admin
+ - Self-service provisioning → Tenant User
+ - Org config, IDP, org users → Tenant Admin
+ - Internal system stories ("As the CaaS system...") should not exist
+ A sanitization story under Cloud Provider Admin is a misattribution.
+
+ - id: scope-accuracy
+ name: "In Scope / Out of Scope Accuracy"
+ weight: important
+ prompt: |
+ Compare In Scope and Out of Scope items against the gold standard.
+ Flag items in the wrong section (e.g., billing In Scope when gold
+ has it Out of Scope). Flag missing items that appear in the gold.
+ Flag items the generated added that the gold doesn't have — are they
+ accurate or scope creep?
+
+ - id: design-leakage
+ name: "Design Leakage"
+ weight: critical
+ prompt: |
+ Check for internal implementation details that don't belong in a PRD:
+ controller names, reconciler logic, finalizer behavior, playbook
+ parameters, CRD field names, internal conditions, agent/InfraEnv
+ terminology, AAP job parameters. Platform vocabulary (ClusterOrder,
+ BareMetalInstance, Hosted Control Planes) is acceptable.
+
+ - id: problem-statement
+ name: "Problem Statement Quality"
+ weight: important
+ prompt: |
+ The Problem Statement should describe user pain and cost of inaction
+ only. It should NOT describe the solution, what the feature introduces,
+ or how it works. Check for solution language — sentences starting with
+ "This feature introduces...", "The X eliminates...", etc.
+
+ - id: status-visibility
+ name: "Async Status Visibility"
+ weight: important
+ prompt: |
+ If the feature involves asynchronous resource creation, check that
+ status/progress visibility is addressed in In Scope or User Stories.
+ The user should be able to see the current state and failure reasons.
+
+ - id: template-compliance
+ name: "Template Compliance"
+ weight: important
+ prompt: |
+ The OSAC PRD template has exactly 6 sections: Problem Statement,
+ In Scope, Out of Scope, User Stories, Assumptions, Dependencies.
+ Check for extra sections (Risks, Acceptance Criteria, Open Questions,
+ Terminology, Milestone) or missing required sections.
+
+ - id: completeness
+ name: "Content Completeness"
+ weight: important
+ prompt: |
+ Compare the generated PRD against the gold standard section by section.
+ What key requirements are present in the gold but missing in the generated?
+ What did the generated add that the gold doesn't have?
+ Focus on substantive content gaps, not wording differences.
+
+pass_threshold: 6
+fail_on_critical: true
diff --git a/devtools/test-skill/evaluators/judge.py b/devtools/test-skill/evaluators/judge.py
new file mode 100644
index 00000000..d3402d79
--- /dev/null
+++ b/devtools/test-skill/evaluators/judge.py
@@ -0,0 +1,197 @@
+"""LLM judge for evaluating Forge skill outputs against gold standards."""
+
+import json
+import os
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import anthropic
+import yaml
+
+
+def _create_client():
+ vertex_project = os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID")
+ vertex_region = os.environ.get("ANTHROPIC_VERTEX_REGION", "us-east5")
+ if vertex_project:
+ return anthropic.AnthropicVertex(project_id=vertex_project, region=vertex_region)
+ return anthropic.Anthropic()
+
+
+@dataclass
+class CriterionResult:
+ id: str
+ name: str
+ weight: str
+ passed: bool
+ score: int
+ reasoning: str
+ quotes: list[str]
+
+
+@dataclass
+class EvalReport:
+ skill: str
+ generated_path: str
+ gold_path: str
+ results: list[CriterionResult]
+ pass_threshold: int = 6
+ fail_on_critical: bool = True
+
+ @property
+ def total_passed(self) -> int:
+ return sum(1 for r in self.results if r.passed)
+
+ @property
+ def total_criteria(self) -> int:
+ return len(self.results)
+
+ @property
+ def total_score(self) -> int:
+ return sum(r.score for r in self.results)
+
+ @property
+ def max_score(self) -> int:
+ return len(self.results) * 2
+
+ @property
+ def critical_failures(self) -> list[CriterionResult]:
+ return [r for r in self.results if r.weight == "critical" and not r.passed]
+
+ @property
+ def overall_pass(self) -> bool:
+ if self.fail_on_critical and self.critical_failures:
+ return False
+ return self.total_passed >= self.pass_threshold
+
+ @property
+ def grade(self) -> str:
+ pct = self.total_score / self.max_score if self.max_score else 0
+ if pct >= 0.9 and not self.critical_failures:
+ return "A"
+ if pct >= 0.75 and not self.critical_failures:
+ return "B"
+ if pct >= 0.6:
+ return "C"
+ return "D"
+
+
+def load_criteria(criteria_path: Path) -> dict:
+ with open(criteria_path) as f:
+ return yaml.safe_load(f)
+
+
+def _extract_json(text: str) -> dict | None:
+ """Try to extract a JSON object from text that may contain preamble."""
+ if text.startswith("```"):
+ text = text.split("\n", 1)[1].rsplit("```", 1)[0]
+
+ try:
+ return json.loads(text.strip())
+ except json.JSONDecodeError:
+ pass
+
+ match = re.search(r'\{[^{}]*"pass"\s*:', text)
+ if match:
+ start = match.start()
+ depth = 0
+ for i in range(start, len(text)):
+ if text[i] == '{':
+ depth += 1
+ elif text[i] == '}':
+ depth -= 1
+ if depth == 0:
+ try:
+ return json.loads(text[start:i + 1])
+ except json.JSONDecodeError:
+ break
+
+ return None
+
+
+def judge_criterion(
+ client,
+ model: str,
+ criterion: dict,
+ generated: str,
+ gold: str,
+) -> CriterionResult:
+ system = (
+ "You are a document quality judge. Score the generated document against "
+ "the gold standard for the specific criterion described.\n\n"
+ "CRITICAL: Return ONLY a JSON object. No thinking, no analysis, no preamble.\n"
+ "Do NOT explain your reasoning before the JSON. Start your response with {.\n\n"
+ "JSON schema:\n"
+ '{"pass": true/false, "score": 0-2, "reasoning": "one sentence", "quotes": ["relevant quote"]}\n\n'
+ "Scoring: 0 = fails the criterion, 1 = partially meets, 2 = fully meets.\n"
+ "Keep reasoning to one sentence. Keep quotes short (under 100 chars each, max 3)."
+ )
+
+ user = (
+ f"## Criterion: {criterion['name']}\n"
+ f"{criterion['prompt']}\n\n"
+ f"## Generated Document\n```\n{generated}\n```\n\n"
+ f"## Gold Standard Document\n```\n{gold}\n```"
+ )
+
+ response = client.messages.create(
+ model=model,
+ max_tokens=2048,
+ system=system,
+ messages=[{"role": "user", "content": user}],
+ )
+
+ text = response.content[0].text.strip()
+ data = _extract_json(text)
+
+ if data is None:
+ return CriterionResult(
+ id=criterion["id"],
+ name=criterion["name"],
+ weight=criterion.get("weight", "important"),
+ passed=False,
+ score=0,
+ reasoning=f"Judge returned unparseable response: {text[:200]}",
+ quotes=[],
+ )
+
+ return CriterionResult(
+ id=criterion["id"],
+ name=criterion["name"],
+ weight=criterion.get("weight", "important"),
+ passed=data.get("pass", False),
+ score=data.get("score", 0),
+ reasoning=data.get("reasoning", ""),
+ quotes=data.get("quotes", []),
+ )
+
+
+def evaluate(
+ criteria_path: Path,
+ generated_path: Path,
+ gold_path: Path,
+) -> EvalReport:
+ config = load_criteria(criteria_path)
+ model = config.get("judge_model", "claude-sonnet-4-6")
+
+ generated = generated_path.read_text()
+ gold = gold_path.read_text()
+
+ client = _create_client()
+ results = []
+
+ for criterion in config.get("criteria", []):
+ print(f" Judging: {criterion['name']}...", end="", flush=True)
+ result = judge_criterion(client, model, criterion, generated, gold)
+ status = "PASS" if result.passed else "FAIL"
+ print(f" {status} {result.score}/2")
+ results.append(result)
+
+ return EvalReport(
+ skill=config.get("skill", "unknown"),
+ generated_path=str(generated_path),
+ gold_path=str(gold_path),
+ results=results,
+ pass_threshold=config.get("pass_threshold", 6),
+ fail_on_critical=config.get("fail_on_critical", True),
+ )
diff --git a/devtools/test-skill/evaluators/reports.py b/devtools/test-skill/evaluators/reports.py
new file mode 100644
index 00000000..d1721950
--- /dev/null
+++ b/devtools/test-skill/evaluators/reports.py
@@ -0,0 +1,123 @@
+"""Report generators for evaluation results."""
+
+import html as html_mod
+import json
+from pathlib import Path
+
+from .judge import EvalReport
+
+
+def print_terminal(report: EvalReport):
+ GREEN = "\033[92m"
+ RED = "\033[91m"
+ YELLOW = "\033[93m"
+ RESET = "\033[0m"
+ BOLD = "\033[1m"
+
+ print(f"\n{BOLD}PRD Evaluation: {report.skill}{RESET}")
+ print("=" * 55)
+
+ for r in report.results:
+ color = GREEN if r.passed else RED
+ status = "PASS" if r.passed else "FAIL"
+ weight_marker = " *" if r.weight == "critical" else ""
+ reasoning_short = r.reasoning[:60] if r.reasoning else ""
+ print(f" {r.name:<28} {color}{status}{RESET} {r.score}/2 {reasoning_short}{weight_marker}")
+
+ print("=" * 55)
+ overall = f"{GREEN}PASS{RESET}" if report.overall_pass else f"{RED}FAIL{RESET}"
+ grade_colors = {"A": GREEN, "B": GREEN, "C": YELLOW, "D": RED}
+ gc = grade_colors.get(report.grade, RESET)
+ print(
+ f" Total: {report.total_passed}/{report.total_criteria} passed | "
+ f"Score: {report.total_score}/{report.max_score} | "
+ f"Grade: {gc}{report.grade}{RESET} | {overall}"
+ )
+
+ if report.critical_failures:
+ print(f"\n {RED}Critical failures:{RESET}")
+ for r in report.critical_failures:
+ print(f" - {r.name}: {r.reasoning[:80]}")
+
+ print()
+
+
+def save_json(report: EvalReport, output_dir: Path):
+ output_dir.mkdir(parents=True, exist_ok=True)
+ data = {
+ "skill": report.skill,
+ "generated_path": report.generated_path,
+ "gold_path": report.gold_path,
+ "overall_pass": report.overall_pass,
+ "grade": report.grade,
+ "total_passed": report.total_passed,
+ "total_criteria": report.total_criteria,
+ "total_score": report.total_score,
+ "max_score": report.max_score,
+ "results": [
+ {
+ "id": r.id,
+ "name": r.name,
+ "weight": r.weight,
+ "passed": r.passed,
+ "score": r.score,
+ "reasoning": r.reasoning,
+ "quotes": r.quotes,
+ }
+ for r in report.results
+ ],
+ }
+ path = output_dir / "results.json"
+ with open(path, "w") as f:
+ json.dump(data, f, indent=2)
+ print(f"JSON report: {path}")
+
+
+def save_html(report: EvalReport, output_dir: Path):
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ rows = []
+ for r in report.results:
+ color = "#4eca8b" if r.passed else "#e85c5c"
+ status = "PASS" if r.passed else "FAIL"
+ weight = f' *' if r.weight == "critical" else ""
+ quotes_html = ""
+ if r.quotes:
+ quotes_html = "
".join(f'{html_mod.escape(q[:100])}' for q in r.quotes[:3])
+ rows.append(
+ f'
Generated: {html_mod.escape(report.generated_path)}
Gold: {html_mod.escape(report.gold_path)}
| Criterion | Result | Score | Reasoning | Evidence |
|---|