docs: 보스 설계 문서와 Codex YAML 도구 추가

- 프로젝트 AGENTS 지침에 커밋 후 푸시 확인 규칙과 런타임 콘텐츠 에이전트 규칙을 보강
- 보스 설계 플레이북과 멀티플레이 보스 설계 철학 문서를 추가해 사람용 설계 기준을 정리
- Codex-Tools 디렉터리에 Markdown→YAML→검증 파이프라인 스크립트와 프롬프트, 스키마, 설정 파일을 추가
This commit is contained in:
2026-04-06 14:03:49 +09:00
parent c48d1bee52
commit c8edf838fd
12 changed files with 1201 additions and 1 deletions

18
Codex-Tools/AGENTS.md Normal file
View File

@@ -0,0 +1,18 @@
# Codex Automation Agent Rules
Purpose:
Run the design-to-data pipeline for boss encounters.
Pipeline:
1. Read Markdown boss design doc from the design vault.
2. Convert it to boss YAML.
3. Validate the YAML using boss design rules.
4. Write a validation report next to the design doc.
5. Write runtime YAML into the game project.
Rules:
- Markdown is the source of truth.
- YAML is derived data.
- Validation should never modify the source Markdown.
- Do not invent mechanics.
- If a field is missing, record that as uncertainty or an issue.

22
Codex-Tools/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Codex Boss Pipeline Tools
This directory contains helper scripts for a Markdown -> YAML -> Validation workflow.
## Files
- `skills/generate_boss_yaml.py`: generate runtime YAML from a Markdown boss design document.
- `skills/validate_boss_yaml.py`: validate a generated boss YAML and emit a Markdown report.
- `skills/run_boss_pipeline.py`: run both steps together.
- `config/paths.example.yaml`: copy and edit into `paths.yaml` for your environment.
## Recommended flow
1. Write `/mnt/smb/Obsidian Vault/Colosseum/보스/<boss>/<boss> 기획.md`
2. Run `run_boss_pipeline.py`
3. Review `<boss>_validation.md`
4. Edit the Markdown source, then rerun.
## Example
```bash
python skills/run_boss_pipeline.py \
--md "/mnt/smb/Obsidian Vault/Colosseum/보스/드로그/드로그 기획.md" \
--config config/paths.yaml
```

View File

@@ -0,0 +1,8 @@
# Fixed configuration keys for the boss pipeline.
# These names are now the canonical version used by all Python skills.
design_root: "/mnt/smb/Obsidian Vault/Colosseum"
game_project_root: "/home/dal4segno/Colosseum"
schema_path: "/home/dal4segno/Colosseum/tools/schemas/boss_schema_v1.yaml"
runtime_boss_output_dir: "/home/dal4segno/Colosseum/content/bosses"
validation_report_suffix: "_validation.md"

View File

@@ -0,0 +1,11 @@
Read the provided Markdown boss design document and convert it into YAML using the boss schema.
Rules:
- Do not invent mechanics.
- Preserve signature mechanic intent.
- Preserve tank / support / dps role pressure intent.
- If a field is missing, leave it empty or null rather than fabricating.
- Prefer machine-readable snake_case values.
- Preserve uncertainty in `source_notes` or `notes` fields.
Return only valid YAML.

View File

@@ -0,0 +1,8 @@
Validate the provided boss YAML against AGENTS.md and the project design philosophy.
Return a Boss Validation Report with:
- PASS / WARNING / FAIL
- scores
- detected issues
- suggested fixes
- explicit note of any missing source information

View File

@@ -0,0 +1,82 @@
# Boss YAML Schema v1
# Authoring target for Codex-readable encounter data.
boss:
metadata:
boss_id: "example_boss_id"
boss_name: "Example Boss"
stage: 1
expected_duration_sec: 600
entity_type: "single" # single | boss_plus_adds | multi_boss | swarm
signature:
name: "Example Signature"
concept: >
One-sentence summary of the signature encounter concept.
player_problem: >
What players must solve.
expected_solution:
tank: []
support: []
dps: []
failure_result:
type: "major_damage" # major_damage | wipe | mechanic_repeat | positioning_loss | other
description: "What happens on failure."
roles:
tank:
pressure_sources: []
failure_results: []
required_pressure: 1.0
support:
pressure_sources: []
failure_results: []
required_pressure: 1.0
dps:
pressure_sources: []
failure_results: []
required_pressure: 1.0
phases:
- phase_id: 1
name: "Learn"
duration_estimate_sec: 120
new_mechanics: []
role_pressure_multiplier:
tank: 1.0
support: 1.0
dps: 1.0
pressure:
sustained:
sources: []
burst:
mechanics: []
dodge_types_required: []
revive:
trigger_response:
enabled: true
reactions: []
revive_risk_level: "medium" # low | medium | high
escalation:
enabled: true
escalation_interval_sec: 60
escalation_types: []
entities:
total_entities: 1
composition:
main_boss: 1
add_behavior:
role: "none"
respawn: false
difficulty:
expected_attempts: 3
primary_failure_causes: []
secondary_failure_causes: []
design_intent: >
Short note on what the fight is trying to test.

View File

@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""Generate boss YAML from a Markdown design document.
Canonical assumptions:
- Markdown is the source of truth.
- YAML is derived runtime data.
- Missing data should remain empty rather than invented.
This parser is intentionally conservative and template-friendly.
"""
from __future__ import annotations
import argparse
import os
import re
from dataclasses import dataclass
from typing import Dict, List, Optional
import yaml
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$", re.MULTILINE)
KV_LINE_RE = re.compile(r"^([A-Za-z0-9_가-힣 /()\-]+):\s*(.*?)\s*$")
SIGNATURE_SECTION_ALIASES = ["signature_pattern", "시그니처_패턴"]
CORE_CONCEPT_ALIASES = ["core_concept", "concept", "컨셉", "핵심_개념", "핵심_컨셉"]
PLAYER_PROBLEM_ALIASES = ["player_problem", "문제", "플레이어_문제", "플레이어_과제"]
FAILURE_RESULT_ALIASES = ["failure_result", "실패_결과"]
EXPECTED_SOLUTION_ALIASES = ["expected_solution", "해결_방법", "정답", "기대_해법", "예상_해법"]
ROLE_ALIASES = {
"tank": ["tank", "탱커"],
"support": ["support", "지원", "서포트"],
"dps": ["dps", "딜러"],
}
SUSTAINED_PRESSURE_SECTION_ALIASES = ["sustained_pressure_design", "지속_압박_설계"]
BURST_PRESSURE_SECTION_ALIASES = ["burst_pressure_design", "순간_압박_설계", "고점_압박_설계"]
TIME_ESCALATION_SECTION_ALIASES = ["time_escalation", "시간_압박", "시간_경과_강화"]
REVIVE_SECTION_ALIASES = ["revive_interaction_design", "부활_상호작용", "부활_상호작용_설계"]
DIFFICULTY_SECTION_ALIASES = ["difficulty_intent", "난이도_의도"]
EXPECTED_ATTEMPTS_LABELS = ["Expected Attempts", "예상 시도 횟수"]
PRIMARY_FAILURE_CAUSE_LABELS = ["Primary Failure Cause", "주 실패 원인"]
SECONDARY_FAILURE_CAUSE_LABELS = ["Secondary Failure Cause", "보조 실패 원인"]
@dataclass
class Section:
level: int
title: str
content: str
def slugify(text: str) -> str:
text = text.strip().lower()
text = re.sub(r"\s+", "_", text)
text = re.sub(r"[^a-z0-9_가-힣]", "", text)
return text or "boss"
def normalize_title(title: str) -> str:
t = title.strip().lower().replace("", "-")
t = re.sub(r"[^a-z0-9가-힣]+", "_", t)
return t.strip("_")
def maybe_int(text: Optional[str]) -> Optional[int]:
if not text:
return None
m = re.search(r"(\d+)", text)
return int(m.group(1)) if m else None
def parse_sections(markdown: str) -> List[Section]:
matches = list(HEADING_RE.finditer(markdown))
if not matches:
return [Section(level=1, title="document", content=markdown.strip())]
sections: List[Section] = []
for i, match in enumerate(matches):
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown)
sections.append(
Section(
level=len(match.group(1)),
title=match.group(2).strip(),
content=markdown[start:end].strip(),
)
)
return sections
def section_map(sections: List[Section]) -> Dict[str, Section]:
mapping: Dict[str, Section] = {}
for section in sections:
key = normalize_title(section.title)
if key not in mapping:
mapping[key] = section
return mapping
def find_line_value(markdown: str, labels: List[str]) -> Optional[str]:
normalized = {normalize_title(label) for label in labels}
for raw in markdown.splitlines():
line = raw.strip().lstrip("- ")
if not line or ":" not in line:
continue
left, right = line.split(":", 1)
if normalize_title(left) in normalized:
value = right.strip()
return value or None
return None
def first_section(smap: Dict[str, Section], aliases: List[str]) -> Optional[Section]:
return next((smap[alias] for alias in aliases if alias in smap), None)
def first_content_value(section: Optional[Section]) -> Optional[str]:
if not section:
return None
for raw in section.content.splitlines():
line = raw.strip().lstrip("-* ").strip()
if line:
return line
return None
def collect_child_sections(sections: List[Section], parent_aliases: List[str]) -> List[Section]:
normalized_aliases = set(parent_aliases)
for index, section in enumerate(sections):
if normalize_title(section.title) not in normalized_aliases:
continue
children: List[Section] = []
parent_level = section.level
for child in sections[index + 1 :]:
if child.level <= parent_level:
break
children.append(child)
return children
return []
def parse_inline_list(section: Optional[Section]) -> List[str]:
if not section:
return []
items: List[str] = []
for raw in section.content.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith(("-", "*")):
items.append(slugify(line.strip("-* "))[:64])
return items
def extract_role_pressure(smap: Dict[str, Section]) -> Dict[str, dict]:
config = {
"tank": ["tank_pressure", "탱커_압박", "tank"],
"support": ["support_pressure", "지원_압박", "support"],
"dps": ["dps_pressure", "딜러_압박", "dps"],
}
output: Dict[str, dict] = {}
for role_name, aliases in config.items():
section = next((smap[a] for a in aliases if a in smap), None)
output[role_name] = {
"required_pressure": None,
"pressure_sources": parse_inline_list(section),
"failure_results": [],
"notes": section.content.strip() if section else None,
}
return output
def extract_signature(markdown: str, sections: List[Section], smap: Dict[str, Section]) -> dict:
signature_section = first_section(smap, SIGNATURE_SECTION_ALIASES)
signature_name = find_line_value(markdown, ["Signature Name", "Signature Pattern", "시그니처 패턴", "시그니처 이름"])
core_concept = None
player_problem = None
failure_description = None
expected_solution: Dict[str, List[str]] = {}
if signature_section:
sub_sections = collect_child_sections(sections, SIGNATURE_SECTION_ALIASES)
sub_map = section_map(sub_sections)
signature_name = signature_name or first_content_value(
first_section(sub_map, ["signature_name", "시그니처_이름"])
)
core_concept = first_section(sub_map, CORE_CONCEPT_ALIASES)
player_problem = first_section(sub_map, PLAYER_PROBLEM_ALIASES)
failure_result = first_section(sub_map, FAILURE_RESULT_ALIASES)
expected = first_section(sub_map, EXPECTED_SOLUTION_ALIASES)
core_concept = core_concept.content.strip() if core_concept else None
player_problem = player_problem.content.strip() if player_problem else None
failure_description = failure_result.content.strip() if failure_result else None
if expected:
current_role: Optional[str] = None
role_items: Dict[str, List[str]] = {"tank": [], "support": [], "dps": []}
for raw in expected.content.splitlines():
line = raw.strip()
if not line:
continue
role_key = normalize_title(line.rstrip(":"))
for canonical_role, aliases in ROLE_ALIASES.items():
if role_key in {normalize_title(alias) for alias in aliases}:
current_role = canonical_role
break
else:
current_role = current_role
if current_role and role_key in {
normalize_title(alias)
for aliases in ROLE_ALIASES.values()
for alias in aliases
}:
continue
if current_role and line.startswith(("-", "*")):
role_items[current_role].append(line.strip("-* "))
elif current_role:
role_items[current_role].append(line)
expected_solution = {k: v for k, v in role_items.items() if v}
return {
"name": signature_name,
"concept": core_concept,
"player_problem": player_problem,
"expected_solution": expected_solution,
"failure_result": {
"type": None,
"description": failure_description,
},
}
def extract_phases(sections: List[Section]) -> List[dict]:
phases: List[dict] = []
for section in sections:
normalized = normalize_title(section.title)
if normalized.startswith("phase") or normalized.startswith("페이즈"):
phase_id = maybe_int(section.title) or len(phases) + 1
mechanics = parse_inline_list(Section(section.level, section.title, section.content))
phases.append(
{
"phase_id": phase_id,
"name": section.title,
"duration_estimate_sec": None,
"new_mechanics": mechanics,
"role_pressure_multiplier": {"tank": None, "support": None, "dps": None},
"notes": section.content.strip() or None,
}
)
return phases
def extract_escalation(markdown: str, smap: Dict[str, Section]) -> dict:
section = first_section(smap, TIME_ESCALATION_SECTION_ALIASES)
interval = maybe_int(find_line_value(markdown, ["Escalation Interval Sec", "Escalation Interval", "강화 간격", "시간 압박 간격"]))
types = parse_inline_list(section)
return {
"enabled": bool(section),
"escalation_interval_sec": interval,
"escalation_types": types,
"notes": section.content.strip() if section else None,
}
def extract_revive(markdown: str, smap: Dict[str, Section]) -> dict:
section = first_section(smap, REVIVE_SECTION_ALIASES)
level = find_line_value(markdown, ["Revive Risk Level", "부활 위험도"])
return {
"trigger_response": {
"enabled": bool(section),
"reactions": parse_inline_list(section),
},
"revive_risk_level": level,
"notes": section.content.strip() if section else None,
}
def generate_boss_yaml(markdown: str, md_path: str, boss_id_override: Optional[str] = None) -> dict:
sections = parse_sections(markdown)
smap = section_map(sections)
boss_name = find_line_value(markdown, ["Boss Name", "보스 이름"]) or os.path.splitext(os.path.basename(md_path))[0]
boss_id = boss_id_override or find_line_value(markdown, ["Boss ID", "boss_id", "보스 ID", "보스 아이디"]) or slugify(boss_name)
stage = maybe_int(find_line_value(markdown, ["Stage", "스테이지"]))
duration_sec = maybe_int(find_line_value(markdown, ["Estimated Duration Sec", "Estimated Duration", "전투 시간"]))
entity_type = find_line_value(markdown, ["Entity Type", "개체 타입"])
sustained_section = first_section(smap, SUSTAINED_PRESSURE_SECTION_ALIASES)
burst_section = first_section(smap, BURST_PRESSURE_SECTION_ALIASES)
difficulty_section = first_section(smap, DIFFICULTY_SECTION_ALIASES)
data = {
"boss": {
"metadata": {
"boss_id": boss_id,
"boss_name": boss_name,
"stage": stage,
"expected_duration_sec": duration_sec,
"entity_type": entity_type,
"source_document": md_path,
},
"signature": extract_signature(markdown, sections, smap),
"roles": extract_role_pressure(smap),
"phases": extract_phases(sections),
"pressure": {
"sustained": {
"sources": parse_inline_list(sustained_section),
"notes": sustained_section.content.strip() if sustained_section else None,
},
"burst": {
"mechanics": parse_inline_list(burst_section),
"notes": burst_section.content.strip() if burst_section else None,
},
"dodge_types_required": [],
},
"revive": extract_revive(markdown, smap),
"escalation": extract_escalation(markdown, smap),
"entities": {
"total_entities": None,
"composition": {},
"add_behavior": {"role": None, "respawn": None},
},
"difficulty": {
"expected_attempts": maybe_int(find_line_value(markdown, EXPECTED_ATTEMPTS_LABELS)),
"primary_failure_causes": [
value
for value in [find_line_value(markdown, PRIMARY_FAILURE_CAUSE_LABELS)]
if value
],
"secondary_failure_causes": [
value
for value in [find_line_value(markdown, SECONDARY_FAILURE_CAUSE_LABELS)]
if value
],
"notes": difficulty_section.content.strip() if difficulty_section else None,
},
"source_notes": {
"unparsed_sections": [
{"title": section.title, "content": section.content[:500]}
for section in sections
if section.level <= 3
]
},
}
}
return data
def main() -> None:
parser = argparse.ArgumentParser(description="Generate boss YAML from Markdown")
parser.add_argument("--md", required=True, help="Path to boss markdown design document")
parser.add_argument("--output", required=True, help="Path to output YAML")
parser.add_argument("--boss-id", default=None, help="Optional boss_id override")
args = parser.parse_args()
with open(args.md, "r", encoding="utf-8") as file:
markdown = file.read()
data = generate_boss_yaml(markdown, args.md, boss_id_override=args.boss_id)
os.makedirs(os.path.dirname(args.output), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as file:
yaml.safe_dump(data, file, allow_unicode=True, sort_keys=False, width=100)
print(args.output)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Run the full Markdown -> YAML -> Validation pipeline.
Canonical config keys used everywhere:
- design_root
- game_project_root
- schema_path
- runtime_boss_output_dir
- validation_report_suffix
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict
import yaml
HERE = Path(__file__).resolve().parent
REQUIRED_CONFIG_KEYS = [
"design_root",
"game_project_root",
"schema_path",
"runtime_boss_output_dir",
"validation_report_suffix",
]
def load_config(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as file:
data = yaml.safe_load(file) or {}
missing = [key for key in REQUIRED_CONFIG_KEYS if key not in data]
if missing:
raise SystemExit(f"Missing config keys in {path}: {', '.join(missing)}")
return data
def infer_boss_id(md_path: str) -> str:
stem = Path(md_path).stem
stem = stem.replace(" 기획", "").replace("_기획", "")
stem = stem.strip()
return stem.lower().replace(" ", "_")
def main() -> None:
parser = argparse.ArgumentParser(description="Run boss pipeline")
parser.add_argument("--md", required=True, help="Path to boss Markdown document")
parser.add_argument("--config", required=True, help="Path to paths.yaml config")
parser.add_argument("--boss-id", default=None, help="Optional runtime boss_id / yaml filename override")
args = parser.parse_args()
config = load_config(args.config)
md_path = os.path.abspath(args.md)
if not os.path.exists(md_path):
raise SystemExit(f"Markdown file not found: {md_path}")
boss_id = args.boss_id or infer_boss_id(md_path)
yaml_output = os.path.join(config["runtime_boss_output_dir"], f"{boss_id}.yaml")
report_output = os.path.join(
os.path.dirname(md_path),
f"{boss_id}{config['validation_report_suffix']}",
)
schema_path = config["schema_path"]
if not os.path.exists(schema_path):
print(f"Warning: schema_path does not exist yet: {schema_path}")
gen_script = str(HERE / "generate_boss_yaml.py")
val_script = str(HERE / "validate_boss_yaml.py")
subprocess.run(
[sys.executable, gen_script, "--md", md_path, "--output", yaml_output, "--boss-id", boss_id],
check=True,
)
subprocess.run(
[sys.executable, val_script, "--yaml", yaml_output, "--output", report_output],
check=True,
)
print("Pipeline complete")
print(f"Markdown: {md_path}")
print(f"YAML: {yaml_output}")
print(f"Report: {report_output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Validate boss YAML using the project's multiplayer boss rules."""
from __future__ import annotations
import argparse
import os
from typing import Any, Dict, List, Tuple
import yaml
def load_yaml(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def score_to_label(issues: List[Tuple[str, str]]) -> str:
severities = [severity for severity, _ in issues]
if "ERROR" in severities:
return "FAIL"
if "WARNING" in severities:
return "WARNING"
return "PASS"
def validate(data: Dict[str, Any]) -> Tuple[Dict[str, int], List[Tuple[str, str]], List[str]]:
boss = data.get("boss", {})
issues: List[Tuple[str, str]] = []
fixes: List[str] = []
scores = {
"Role Integrity Score": 100,
"Pattern Diversity Score": 100,
"Execution Fairness Score": 100,
"Revive Risk Score": 100,
"Time Pressure Score": 100,
}
roles = boss.get("roles", {})
for role_name in ["tank", "support", "dps"]:
role = roles.get(role_name)
if not role:
issues.append(("ERROR", f"Missing role definition: {role_name}"))
scores["Role Integrity Score"] -= 25
fixes.append(f"Add `{role_name}` role pressure definition.")
continue
if not role.get("notes") and not role.get("pressure_sources"):
issues.append(("WARNING", f"No pressure details defined for {role_name}"))
scores["Role Integrity Score"] -= 12
fixes.append(f"Describe how the boss pressures {role_name} players.")
signature = boss.get("signature", {})
if not signature.get("name"):
issues.append(("ERROR", "Missing signature mechanic name"))
scores["Pattern Diversity Score"] -= 30
fixes.append("Add a named signature mechanic.")
if not signature.get("player_problem"):
issues.append(("WARNING", "Signature mechanic has no clear player problem"))
scores["Pattern Diversity Score"] -= 15
fixes.append("Describe the player problem the signature mechanic creates.")
if not signature.get("expected_solution"):
issues.append(("WARNING", "Signature mechanic has no clear expected solution"))
scores["Pattern Diversity Score"] -= 15
fixes.append("Describe the expected solution for the signature mechanic.")
if not (signature.get("failure_result") or {}).get("description"):
issues.append(("WARNING", "Signature mechanic has no failure result"))
scores["Execution Fairness Score"] -= 12
fixes.append("Define what happens when the signature mechanic is failed.")
phases = boss.get("phases") or []
if len(phases) < 3:
issues.append(("ERROR", f"Insufficient phases: {len(phases)} found, expected at least 3"))
scores["Pattern Diversity Score"] -= 30
fixes.append("Add at least 3 phases, ideally 4 for a ~10 minute fight.")
elif len(phases) < 4:
issues.append(("WARNING", "Only 3 phases defined; 4 is recommended for ~10 minute encounters"))
scores["Pattern Diversity Score"] -= 8
for phase in phases:
if not phase.get("new_mechanics") and not phase.get("notes"):
issues.append(("WARNING", f"Phase {phase.get('phase_id')} has no new mechanics listed"))
scores["Pattern Diversity Score"] -= 5
fixes.append(f"List at least one new mechanic for phase {phase.get('phase_id')}.")
pressure = boss.get("pressure", {})
dodge_types = pressure.get("dodge_types_required") or []
if dodge_types and len(dodge_types) < 3:
issues.append(("WARNING", "Fewer than 3 dodge response types defined"))
scores["Execution Fairness Score"] -= 10
fixes.append("List at least 3 dodge response types, such as timing, directional, and chained.")
revive = boss.get("revive", {})
revive_enabled = (revive.get("trigger_response") or {}).get("enabled")
if revive_enabled:
if not (revive.get("trigger_response") or {}).get("reactions"):
issues.append(("WARNING", "Revive response enabled but no reactions defined"))
scores["Revive Risk Score"] -= 20
fixes.append("Define at least one revive-triggered boss reaction.")
else:
issues.append(("WARNING", "No revive-triggered boss response defined"))
scores["Revive Risk Score"] -= 25
fixes.append("Add a risky revive interaction if revive exists in combat.")
escalation = boss.get("escalation", {})
if not escalation.get("enabled"):
issues.append(("ERROR", "No time escalation defined"))
scores["Time Pressure Score"] -= 35
fixes.append("Add soft-enrage or time-based escalation to prevent infinite sustain.")
else:
if not escalation.get("escalation_types"):
issues.append(("WARNING", "Escalation enabled but escalation types are empty"))
scores["Time Pressure Score"] -= 10
fixes.append("Specify how the boss escalates over time.")
if not escalation.get("escalation_interval_sec"):
issues.append(("WARNING", "Escalation interval is not defined"))
scores["Time Pressure Score"] -= 8
fixes.append("Specify an escalation interval in seconds.")
for score_name in list(scores.keys()):
scores[score_name] = max(0, scores[score_name])
return scores, issues, fixes
def render_report(yaml_path: str, data: Dict[str, Any], scores: Dict[str, int], issues: List[Tuple[str, str]], fixes: List[str]) -> str:
boss = data.get("boss", {})
metadata = boss.get("metadata", {})
result = score_to_label(issues)
lines = [
"# Boss Validation Report",
"",
f"Boss ID: {metadata.get('boss_id')}",
f"Boss Name: {metadata.get('boss_name')}",
f"Source YAML: `{yaml_path}`",
"",
f"Result: **{result}**",
"",
]
for score_name, value in scores.items():
lines.append(f"- {score_name}: {value}")
lines.extend(["", "## Detected Issues"])
if issues:
for severity, message in issues:
lines.append(f"- **{severity}**: {message}")
else:
lines.append("- None")
lines.extend(["", "## Suggested Fixes"])
if fixes:
seen = set()
for item in fixes:
if item not in seen:
lines.append(f"- {item}")
seen.add(item)
else:
lines.append("- No changes required.")
lines.extend([
"",
"## Notes",
"- Markdown source remains the source of truth.",
"- Regenerate YAML after editing the Markdown design document.",
"",
])
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Validate boss YAML")
parser.add_argument("--yaml", required=True, help="Path to boss YAML")
parser.add_argument("--output", required=True, help="Path to output Markdown report")
args = parser.parse_args()
data = load_yaml(args.yaml)
scores, issues, fixes = validate(data)
report = render_report(args.yaml, data, scores, issues, fixes)
os.makedirs(os.path.dirname(args.output), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as file:
file.write(report)
print(args.output)
if __name__ == "__main__":
main()