#!/usr/bin/env python3 """Generate .claude/agents/*.md from org-os capability-families + role-profiles. Agent 세트는 collaboration-default에 따라 갈린다(실제 subagent 분리): - fan-out family(멤버 >= 2): 멤버 role마다 **개별 subagent**(fan-out 워커). 각자 격리 context에서 자기 관점만 작업하고 자기 .report.yaml을 쓴 뒤 경로를 반환한다. 분리 = context 오염 방지. - collapse family: planner가 후보 중 concrete role 하나를 선택하고 그 role만 자기 method-skill로 실행. - 단일 멤버 fan-out / ORCH: concrete role card 1개. Family는 `.claude/agents` 카드가 아니라 generated family registry의 metadata다. `fam-*` 실행 identity/router/resolver card는 생성하지 않는다. Orchestrator(메인 세션, Agent 도구 보유)가 fan-out을 구동한다. (현재 Claude Code는 nested subagent도 지원하지만, 이 하네스의 fan-out 종합 계약은 여전히 Orchestrator가 원본 보고서를 전부 읽고 종합하는 것을 기본으로 한다 — context 오염/이중종합 방지. 중첩 호출은 예외적으로만.) 각 에이전트 본문은 role-profiles.yaml의 실제 관점(관점)/시야(시야)/책임(책임)/근거(evidence-basis)를 담는다. Usage: python3 .claude/hooks/gen_agents.py [--check] (no args) -> 기존 .md 정리 후 (재)생성, 개수 출력 --check -> 메모리 검증만(개수·본문), 파일 안 씀 """ import glob import os import sys import yaml ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) REG = os.path.join(ROOT, "org-os", "00-role-registry") FAMILIES = os.path.join(REG, "capability-families.yaml") PROFILES = os.path.join(REG, "role-profiles.yaml") WORKING_METHODS = os.path.join(REG, "role-working-methods.yaml") # v1 fallback(단일 파일) RWM_DIR = os.path.join(REG, "role-working-methods") # P3: 파일분리 SoT MATRIX = os.path.join(REG, "tool-permission-matrix.yaml") # #10: tools의 단일 정본(SoT) REGISTRY = os.path.join(REG, "method-skill-registry.yaml") # P3: role→method-skill 배선 SoT OUT_DIR = os.path.join(ROOT, ".claude", "agents") # #10: agent 'tools' 프론트매터는 tool-permission-matrix.yaml의 agent-tools 섹션에서 DERIVE한다 # (하드코딩 금지 — 프론트매터·guard·매트릭스가 서로 다른 정본을 갖던 문제 제거). # TOOLS/DEFAULT_TOOLS는 main()에서 load_tools_from_matrix()로 채운다. TOOLS = {} DEFAULT_TOOLS = "Read, Grep, Glob, Write, WebFetch, WebSearch" # 정본 로드 전 ADVISORY 폴백 MREG = {} # P3: method-skill-registry(main에서 load) — 카드 skills: frontmatter 파생 def load_tools_from_matrix(fams): """tool-permission-matrix.yaml(agent-tools 정본)에서 family-id -> tools 문자열 맵을 만든다. 반환: (family_id -> 'Tool, Tool, ...' dict, default_tools 문자열). 매트릭스에 agent-tools 섹션이 없거나 프로파일이 미정의면 **명확히 실패**(SoT 불일치 은폐 금지).""" root = yaml.safe_load(open(MATRIX)) or {} at = ((root.get("tool-permission-matrix") or {}).get("agent-tools")) assert isinstance(at, dict), ( "tool-permission-matrix.yaml에 agent-tools 섹션이 없음 — tools의 단일 정본 필요(#10)") profiles = at.get("profiles") or {} def as_str(profile): toks = profiles.get(profile) assert isinstance(toks, list) and toks, \ f"agent-tools.profiles['{profile}'] 미정의/비어있음(tool-permission-matrix)" return ", ".join(toks) default_profile = at.get("default-profile") or "ADVISORY" default_tools = as_str(default_profile) fam_profiles = at.get("family-profiles") or {} # family-profiles의 키가 실제 family인지 검증(오타로 정본이 조용히 무시되는 것 방지) fam_ids = {f["family-id"] for f in fams} for fid in fam_profiles: assert fid in fam_ids, f"agent-tools.family-profiles의 미지 family-id: {fid}" tmap = {} for f in fams: fid = f["family-id"] tmap[fid] = as_str(fam_profiles.get(fid, default_profile)) return tmap, default_tools def dedup(seq): seen, out = set(), [] for x in seq: if x not in seen: seen.add(x) out.append(x) return out def load_working_methods(): """P3: 파일분리(role-working-methods/) 우선, 없으면 단일 파일(v1 fallback).""" idx = os.path.join(RWM_DIR, "index.yaml") if os.path.exists(idx): merged = {} includes = (yaml.safe_load(open(idx)) or {}).get("role-method-contracts", {}).get("includes", []) for inc in includes: d = yaml.safe_load(open(os.path.join(RWM_DIR, inc))) or {} merged.update(d.get("role-working-methods") or {}) if merged: return merged if not os.path.exists(WORKING_METHODS): return {} d = yaml.safe_load(open(WORKING_METHODS)) or {} return (d.get("role-working-methods") or {}) if isinstance(d, dict) else {} def load_method_registry(fams): """method-skill-registry(role→method-skill 배선) 로드 + 키 실존·완전성 검증.""" root = yaml.safe_load(open(REGISTRY))["method-skill-registry"] roles, families = root["roles"], root["families"] fam_ids = {f["family-id"] for f in fams} for fid in families: assert fid in fam_ids, f"method-skill-registry.families 미지 family-id: {fid}" bound = set() for f in fams: bound |= set(f["member-role-ids"]) missing = bound - set(roles) assert not missing, f"method-skill-registry.roles 미등록 agent-bound 역할: {sorted(missing)}" return root def _fw_name(fw): """key-frameworks 항목에서 이름만('OKR (...)' -> 'OKR', 'design-brief (...): ...' -> 'design-brief').""" return str(fw).split(" (")[0].split(":")[0].strip() def method_spine(rids, wm_map, names=None): """P3: 카드에 남는 얇은 절차 잔여(역할-파생). 공통 불변식 재나열 안 함(spec §7). essence 1줄 + 프레임워크 이름 최대 3 + method-skill pointer/load-guard. 전체 절차는 skill.""" entries = [(rid, wm_map.get(rid)) for rid in rids if wm_map.get(rid)] if not entries: return "" multi = len(entries) > 1 lines = ["## 핵심 작업 방법 (전체 절차는 skill)"] for rid, wm in entries: if multi: lines.append(f"### {(names or {}).get(rid, rid)}") wmlist = wm.get("working-method") or [] if wmlist: lines.append(f"- 핵심 접근: {wmlist[0]}") fws = wm.get("key-frameworks") or [] if fws: lines.append("- 주요 프레임워크: " + ", ".join(_fw_name(f) for f in fws[:3])) skill = MREG["roles"][rid]["method-skill"] lines.append(f"- 전체 실무 절차·체크리스트·자기검증·handoff는 `{skill}` skill을 따른다. " "skill 미적재 시 작업 시작 금지.") if multi: lines.append("") return "\n".join(lines).rstrip() # 디자인·비주얼 직무: craft 표준(SKILL)을 두 방식으로 준다(finding #17): # (1) frontmatter `skills:` 로 **전체 skill을 preload**(현재 Claude Code는 subagent도 skill 로드 가능). # (2) 본문에 핵심 제약층 체크리스트를 짧게 embed(in-context 리마인더 — skill 요지를 눈앞에 둔다). # 예전엔 "subagent는 skill을 auto-load 못 한다"는 가정으로 (2)만 했는데, 이제 (1)이 정본 전체를 싣는다. CRAFT = { "DES-PROD": "design-craft", "DES-PLATFORM": "design-craft", "DES-INTERNAL": "design-craft", "DOC-VISUAL": "diagram-craft", } # P3: capability-skill(design-craft/build-loop) 배선은 method-skill-registry로 이관(하드코딩 흡수). # skills_fm_line 이 registry(roles.capability-skills / families.capability-skills)에서 파생한다. def skills_fm_line(kind, *, rid=None): """P3: concrete role/lead card의 registry 파생 skills frontmatter.""" if kind not in ("role", "lead") or not rid: raise ValueError(f"concrete role skill binding required: kind={kind!r}, role={rid!r}") roles = MREG["roles"] sk = [roles[rid]["method-skill"]] sk += roles[rid].get("capability-skills") or [] sk = dedup(sk) return f"skills: [{', '.join(sk)}]\n" if sk else "" def craft_block(rid): skill = CRAFT.get(rid) if not skill: return "" if skill == "diagram-craft": return """## 디자인 craft 표준 (필수 — `.claude/skills/diagram-craft` + `design-craft`) 전문가급 산출의 핵심은 프레임워크 지식이 아니라 **제약층**이다. 빈 추론층은 모델이 generic으로 채운다(제약>묘사). - **abstraction-first**: 도구보다 C4 레벨·독자·전달 메시지를 먼저 정한다. one diagram, one message. - **엔진 우선순위: D2(아키텍처·의존성·중첩, 1급) → Excalidraw(설명·손그림) → Mermaid(폴백만)**. Mermaid로 보여주는 건 실무급 시각자료가 아니다 — 자제한다. - D2 관용구: 중첩 컨테이너로 계층/경계, 큰 그래프는 layout=elk, direction 고정, 테마로 색 통일. render_consult가 `{type: d2, code}`를 d2 CLI로 실물 SVG 렌더. - notation 규율: 스코프 한 줄 제목·범례·일관된 방향·예약색(색은 의미 전용). - self-check: Mermaid로 도망치지 않았나? 아키텍처·의존성이면 D2여야 한다.""" return """## 디자인 craft 표준 (필수 — `.claude/skills/design-craft`) 전문가급 산출의 핵심은 프레임워크 지식이 아니라 **제약층**이다(제약>묘사). 빈 추론층은 모델이 generic으로 채운다. - **design-brief를 먼저**(design-brief-spec): brief(무엇/누구/달성) → references → tokens → decisions → donts. - **레퍼런스는 형용사가 아니라 구체 신호**: "modern/clean/minimal" 금지. 구체 제품 3–6개 + 나르는 신호(밀도·간격·색 규율)를 명명한다. - **토큰은 값+의도+경계**(경계 없는 토큰 금지). 컴포넌트는 **판단로직**(언제 A vs B). **명시적 Don'ts 5개+**. - anti-generic self-check: 내 산출을 "modern/clean"으로 설명할 수 있으면 generic이다 — 명명된 레퍼런스로 다시 앵커한다.""" # #9: 실물 산출물(RFC/ADR·data-model·threat-model·api-contract·code)을 report 한 줄로 축소하지 않고 # 실제 파일로 써서 primary-artifacts[]에 등재하게 하는 공통 계약 라인(모든 에이전트 본문에 embed). # 보고서는 실물의 envelope(경로+검증+리스크)다. design/spec/build/completion 유형은 validator가 실존을 강제. PRIMARY_ARTIFACTS_CONTRACT = ( "- **실물 산출물은 보고서로 대체 금지 — primary-artifacts 분리(#9)**: RFC/ADR·데이터모델·" "threat-model·api-contract·실제 코드 같은 실물 deliverable은 **실제 파일로 써서**(Write) " "`primary-artifacts: [{path, kind, sha?, verification}]`에 등재한다. 보고서(.report.yaml)는 그 실물의 " "**경로+검증+리스크를 담는 envelope**이며, 보고서 안 몇 줄 요약으로 실물을 대체하지 않는다. " "design/spec/build/completion 유형 산출은 validate_report가 primary-artifacts 실존(과 receipt)을 강제한다." ) def build_concrete_role_agent(rid, profile, fam, wm_map=None, *, direct=False): """One executable concrete role selected by the role planner. Collapse families expose isolated candidates; single-member families expose one direct role. Family metadata stays in the registry and never becomes an agent card. """ fid = fam["family-id"] name = rid.lower() tools = TOOLS.get(fid, DEFAULT_TOOLS) resp = "\n".join(f" - {value}" for value in profile["responsibilities"]) evidence = "\n".join(f"- {value}" for value in dedup(profile["evidence-basis"])) method = method_spine([rid], wm_map or {}) craft = craft_block(rid) execution = "" if fid in {"FAM-ENG-FRONTEND", "FAM-ENG-BACKEND", "FAM-ENG-SPECIAL", "FAM-PLATFORM-INFRA"}: execution = """## 구현 루프 (build-loop) 1. 호출부와 계약을 inspect한다. 2. smallest safe change를 구현한다. 3. 변경 diff를 inspect한다. 4. targeted verify를 실행한다. 5. broader verify를 실행한다. 6. 실패 시 수정-검증 루프를 반복한다. 7. 실행한 것과 실행하지 않은 것을 정직하게 completion record에 남긴다. """ worker_kind = "direct-role" if direct else "collapse-primary-candidate" worker_desc = "single-member direct worker" if direct else "collapse concrete worker" selection_text = "resolved-worker" if direct else "primary-worker" triggers = fam.get("invocation-triggers", "") exclusions = fam.get("exclusions", "") fm = ( "---\n" f"name: {name}\n" f"description: \"{profile['role-name']} ({rid}) — {fid} {worker_desc}. " f"Use when {triggers}. Do NOT use for {exclusions}. " f"role_selector가 이 역할을 {selection_text}로 선택했을 때만 실행.\"\n" f"tools: {tools}\n" "model: inherit\n" f"{skills_fm_line('role', rid=rid)}" f"family: {fid}\n" f"role-id: {rid}\n" f"collaboration-role: {worker_kind}\n" "---\n" ) body = f""" 당신은 **{profile['role-name']} ({rid})** 입니다. `{fid}`가 아니라 이 concrete 역할로 실행합니다. family 멤버 전체의 방법론을 합치지 않으며, resolver가 `{selection_text}: {rid}`를 반환했을 때만 작업합니다. ## 나의 관점·시야·책임 - 관점: {profile['perspective']} - 시야: {profile['scope']} - 책임: {resp} ## 근거 기준 (evidence-basis) {evidence} {method} {craft} {execution} ## Output contract - context-package의 target-role-agent는 `{name}`이어야 하며 family id는 금지됩니다. - report-header/evidence와 immutable `.report.yaml`을 남깁니다. {PRIMARY_ARTIFACTS_CONTRACT} - external side-effect는 tool-permission-matrix에 따릅니다. """ return name, fm + body def first_sentence(text, n=140): s = str(text).strip().split(". ")[0] return (s[:n] + "…") if len(s) > n else s def build_role_agent(rid, p, fam, wm_map=None): """fan-out family의 한 멤버 role = 개별 subagent(격리 워커).""" fid = fam["family-id"] name = rid.lower() lenses = ", ".join(fam.get("carries-lenses") or []) or "(no lens)" tools = TOOLS.get(fid, DEFAULT_TOOLS) resp = "\n".join(f" - {r}" for r in p["responsibilities"]) ev_lines = "\n".join(f"- {e}" for e in dedup(p["evidence-basis"])) wm_text = method_spine([rid], wm_map or {}) wm_section = (wm_text + "\n\n") if wm_text else "" craft_text = craft_block(rid) craft_section = (craft_text + "\n\n") if craft_text else "" desc = ( f"{p['role-name']} ({rid}) — {fid} fan-out 워커. {first_sentence(p['perspective'])} " f"Use when {fam.get('invocation-triggers', '')}. Orchestrator가 role planner 선택 후 격리 subagent로 호출한다. " f"Do NOT use for {fam.get('exclusions', '')}. " f"Do NOT use for 종합·최종결정(-> Orchestrator/lead) 또는 다른 역할 관점." ) fm = ( "---\n" f"name: {name}\n" f"description: \"{desc.replace(chr(34), chr(39))}\"\n" f"tools: {tools}\n" "model: inherit\n" f"{skills_fm_line('role', rid=rid)}" f"family: {fid}\n" f"role-id: {rid}\n" "collaboration-role: fan-out-worker\n" "---\n" ) body = f""" 당신은 **{p['role-name']} ({rid})** 입니다 — {fid}의 fan-out 워커 (lens: {lenses}). 이 family는 여러 역할을 하나로 합치지 않습니다. 당신은 **자신의 관점만** 독립적으로 담당합니다(context 오염 방지). ## 나의 관점·시야·책임 - 관점: {p['perspective']} - 시야: {p['scope']} - 책임: {resp} ## 근거 기준 (evidence-basis) {ev_lines} {wm_section}{craft_section}## Fan-out 워커 계약 - 나는 **이 한 역할의 관점만** 낸다. 다른 역할의 결론을 대변·종합하지 않는다. - **종합·최종결정은 내가 하지 않는다** — Orchestrator/상위 직무자가 내 보고서(와 동료 역할 보고서들)를 **전부 읽고** 수행한다. - 산출물은 내 `.report.yaml` 하나(report-header BLUF). 최종 메시지로 **그 경로 + 1줄 bottom-line만 반환**한다(요약 본문 금지). ## When invoked 1. context-package(assigned-lens/objective/must-read/task-boundaries)를 확인한다. 없으면 시작하지 않는다. 2. must-read만 읽고 forbidden-context(secrets/PII/raw-log)는 배제한다. 내 관점·근거로만 판단한다. 3. `completion-records/.report.yaml`에 report-header(BLUF)로 시작하는 보고서를 쓴다. 4. 최종 메시지 = 그 경로 + 1줄 bottom-line. ## Output contract (hook이 강제) - report-header 없이 종료 금지. evidence 없는 confidence:High 금지. E4/E5는 실행/실존 아티팩트 필요. {PRIMARY_ARTIFACTS_CONTRACT} - 대표용 MD는 `render_report.py`가 생성한다 — MD를 손으로 쓰지 않는다. - external side-effect(slack/PR/deploy/secret/db-write) 기본 금지(tool-permission-matrix). - 판단/설계는 공식 문서·표준·1차 자료를 근거로(WebFetch/WebSearch/context7 → evidence에 출처 첨부). """ return name, fm + body def build_lead_agent(rid, p, fam, wm_map=None): """fan-out family의 lead-role-id = 프레임+종합을 담당하는 synthesis-lead 에이전트. 워커와 달리 이 역할은 하위 보고서를 전부 읽고 종합한다(synthesized-by).""" fid = fam["family-id"] name = rid.lower() lenses = ", ".join(fam.get("carries-lenses") or []) or "(no lens)" tools = TOOLS.get(fid, DEFAULT_TOOLS) resp = "\n".join(f" - {r}" for r in p["responsibilities"]) ev_lines = "\n".join(f"- {e}" for e in dedup(p["evidence-basis"])) wm_text = method_spine([rid], wm_map or {}) wm_section = (wm_text + "\n\n") if wm_text else "" workers = [m for m in fam["member-role-ids"] if m != rid] worker_names = ", ".join(workers) desc = ( f"{p['role-name']} ({rid}) — {fid} synthesis-lead. {first_sentence(p['perspective'])} " f"Use when {fam.get('invocation-triggers', '')}. Do NOT use for {fam.get('exclusions', '')}. " f"분과 워커({worker_names})를 프레임하고 그 보고서를 전부 읽어 Pyramid Principle로 종합한다. " f"Do NOT use for 개별 분과 관점 생산(-> 해당 워커) 또는 최종 방향 결정(-> FAM-CEO/사람)." ) fm = ( "---\n" f"name: {name}\n" f"description: \"{desc.replace(chr(34), chr(39))}\"\n" f"tools: {tools}\n" "model: inherit\n" f"{skills_fm_line('lead', rid=rid)}" f"family: {fid}\n" f"role-id: {rid}\n" "collaboration-role: synthesis-lead\n" "---\n" ) body = f""" 당신은 **{p['role-name']} ({rid})** 입니다 — {fid}의 **synthesis-lead** (lens: {lenses}). 당신은 엔게이지먼트를 시작(프레임)하고 끝(종합)냅니다. 분과 워커({worker_names})는 각자 관점만 냅니다 — 종합은 당신이 합니다. ## 나의 관점·시야·책임 - 관점: {p['perspective']} - 시야: {p['scope']} - 책임: {resp} ## 근거 기준 (evidence-basis) {ev_lines} {wm_section}## Synthesis-lead 계약 (2단계로 일한다) ### ① FRAME (분과 투입 전) - 문제를 SCQA로 프레이밍하고 **이슈트리(MECE)**로 분해한다. **Day-1 가설**을 세운다. - 각 분과 워커가 무엇을 파고들지 workstream 경계를 정해 context-package로 넘긴다(shared-constraints 포함). ### ② SYNTHESIZE (분과 보고 후) - 분과 워커 `.report.yaml`을 **▶전부 읽는다◀**(synthesis-rehydration — 요약본이 아니라 원본). dissent를 죽이지 않는다. - **Pyramid Principle**로 지배 메시지(governing thought) 아래 논리적으로 종합한다. - 종합 보고서는 `synthesized-by`·`linked-reports`(워커 전부)·`conflicts`를 반드시 포함한다(hook 강제). 이견 없으면 conflicts: []. - 대표용 **문서+덱** 생성을 위해 `storyline:` 블록을 만든다: 각 슬라이드 = 액션타이틀(완결문장·정량주장) + exhibit + evidence. one-message-per-slide. - exhibit 타입 2계열: **정량·개념 차트**는 손제작 SVG 아키타입(waterfall/matrix2x2/harvey/valuechain/benchmark/issuetree/process). **소프트웨어 구조·흐름·의존성 그래프**는 `{{type: d2, code: "...", layout: elk}}`로 실제 diagram-as-code 산출(render_consult가 d2 CLI로 실물 SVG — 1급). Mermaid(`{{type: mermaid}}`)는 최후 폴백만 — 실무급 시각자료가 아니다. 주제에 맞게: 소프트웨어 구조/흐름=D2, 정량 비교=아키타입. ## When invoked 1. context-package(mode/tier/assigned-lens/objective/must-read)를 확인한다. 없으면 시작하지 않는다. 2. FRAME이면 이슈트리·Day-1·workstream 경계를 산출한다. SYNTHESIZE이면 워커 보고서를 전부 읽고 종합+storyline을 산출한다. 3. `completion-records/.report.yaml`에 report-header(BLUF)로 시작하는 보고서를 쓴다. 4. 최종 메시지 = 그 경로 + 1줄 bottom-line. ## Output contract (hook이 강제) - report-header 없이 종료 금지. evidence 없는 confidence:High 금지. E4/E5는 실행/실존 아티팩트 필요. {PRIMARY_ARTIFACTS_CONTRACT} - 종합 보고서는 `synthesized-by` + `linked-reports`(비어있지 않음) + `conflicts` 필수. - 대표용 MD/덱은 `render_consult.py`가 storyline에서 생성한다 — MD를 손으로 쓰지 않는다. - external side-effect(slack/PR/deploy/secret/db-write) 기본 금지(tool-permission-matrix). - 판단/종합은 공식 문서·표준·1차 자료를 근거로(WebFetch/WebSearch → evidence에 출처 첨부). """ return name, fm + body def is_fanout_split(fam): return fam.get("collaboration-default") == "fan-out" and len(fam["member-role-ids"]) >= 2 def main(): global TOOLS, DEFAULT_TOOLS, MREG check = "--check" in sys.argv fams = yaml.safe_load(open(FAMILIES))["capability-families"]["families"] profiles = {p["role-id"]: p for p in yaml.safe_load(open(PROFILES))["role-profiles"]["profiles"]} wm_map = load_working_methods() MREG = load_method_registry(fams) # P3: 카드 skills: frontmatter · spine pointer 파생 # #10: tools를 tool-permission-matrix.yaml(정본)에서 파생한다. TOOLS, DEFAULT_TOOLS = load_tools_from_matrix(fams) # 불변식: audit-capable family는 자기 불변 보고서·설계 산출을 위해 반드시 Write를 갖는다. for f in fams: if f.get("audit-capable"): assert "Write" in TOOLS[f["family-id"]], ( f"{f['family-id']}: audit-capable family인데 Write 미부여 " "— 감사자가 Bash redirection으로 불변 guard를 우회하게 된다(#10). " "tool-permission-matrix agent-tools에서 Write 포함 프로파일로 매핑하라.") agents = [] # (name, content, kind) for fam in fams: lead = fam.get("lead-role-id") if fam.get("collaboration-default") == "collapse": for rid in fam["member-role-ids"]: name, content = build_concrete_role_agent(rid, profiles[rid], fam, wm_map) agents.append((name, content, "collapse-role")) elif lead: # fan-out family with a designated synthesis-lead: concrete lead + worker cards only. lp = profiles.get(lead) assert lp, f"{fam['family-id']}: role-profile 없음 for lead {lead}" name, content = build_lead_agent(lead, lp, fam, wm_map) agents.append((name, content, "lead")) for rid in fam["member-role-ids"]: if rid == lead: continue p = profiles.get(rid) assert p, f"{fam['family-id']}: role-profile 없음 for {rid}" name, content = build_role_agent(rid, p, fam, wm_map) agents.append((name, content, "role")) elif is_fanout_split(fam): # Family metadata is consumed by role_selector; only concrete workers are discoverable. for rid in fam["member-role-ids"]: p = profiles.get(rid) assert p, f"{fam['family-id']}: role-profile 없음 for {rid}" name, content = build_role_agent(rid, p, fam, wm_map) agents.append((name, content, "role")) else: # Single-member fan-out and FAM-ORCH emit their concrete role only. assert len(fam["member-role-ids"]) == 1, ( f"{fam['family-id']}: non-collapse/non-split family must have one member") rid = fam["member-role-ids"][0] name, content = build_concrete_role_agent( rid, profiles[rid], fam, wm_map, direct=True) agents.append((name, content, "direct-role")) # 검증 names = [a[0] for a in agents] assert len(names) == len(set(names)), "중복 agent name" role_n = sum(1 for a in agents if a[2] == "role") lead_n = sum(1 for a in agents if a[2] == "lead") for name, content, kind in agents: meta = yaml.safe_load(content.split("---\n")[1]) assert meta["name"] == name if kind == "collapse-role": assert meta.get("collaboration-role") == "collapse-primary-candidate", f"{name} not collapse candidate" assert meta.get("role-id") and meta.get("skills"), f"{name} missing concrete role/method skill" assert "family 멤버 전체의 방법론을 합치지" in content, f"{name} collapse isolation missing" assert PRIMARY_ARTIFACTS_CONTRACT in content, f"{name} missing primary artifact contract" elif kind == "direct-role": assert meta.get("collaboration-role") == "direct-role", f"{name} not direct role" assert meta.get("role-id") and meta.get("skills"), f"{name} missing concrete role/method skill" assert "family 멤버 전체의 방법론을 합치지" in content, f"{name} direct isolation missing" assert PRIMARY_ARTIFACTS_CONTRACT in content, f"{name} missing primary artifact contract" elif kind == "role": assert meta.get("collaboration-role") == "fan-out-worker", f"{name} not worker" assert "## 나의 관점·시야·책임" in content and "## Fan-out 워커 계약" in content, f"{name} thin" assert "관점:" in content and "evidence-basis" in content if name in ("des-prod", "des-platform", "des-internal", "doc-visual"): assert "디자인 craft 표준" in content, f"{name} missing craft block" if name == "doc-visual": # 다이어그램 직무는 D2 우선·Mermaid 폴백이어야 assert "D2" in content and "폴백" in content, f"{name} craft not D2-first" elif kind == "lead": assert meta.get("collaboration-role") == "synthesis-lead", f"{name} not lead" assert "## Synthesis-lead 계약" in content and "storyline" in content, f"{name} thin lead" assert "synthesized-by" in content, f"{name} lead missing synthesis contract" assert "type: d2" in content, f"{name} lead storyline not D2-first" else: assert "Use PROACTIVELY when" in meta["description"] assert "Do NOT use for" in meta["description"] assert "## 대표 역할별 관점·시야·책임" in content and "관점:" in content, f"{name} not rich" assert "## 협업 실행" in content, f"{name} missing collaboration block" if wm_map: # P3: working-method는 method-skill로 분리 — 카드엔 spine/pointer + skills 참조, full embed 없음 assert "## 일하는 방식" not in content, f"{name} still has full method embed" meta2 = yaml.safe_load(content.split("---\n")[1]) assert "## 핵심 작업 방법" in content, f"{name} missing method spine" assert meta2.get("skills"), f"{name} missing skills frontmatter" # 개수 계약: 75 reference roles == 75 executable concrete cards. assert role_n == 43, f"expected 43 role agents, got {role_n}" assert lead_n == 3, f"expected 3 lead agents, got {lead_n}" collapse_role_n = sum(1 for a in agents if a[2] == "collapse-role") direct_role_n = sum(1 for a in agents if a[2] == "direct-role") assert collapse_role_n == 19, f"expected 19 collapse concrete roles, got {collapse_role_n}" assert direct_role_n == 10, f"expected 10 single-member direct roles, got {direct_role_n}" assert len(agents) == 75, f"expected 75 concrete agents, got {len(agents)}" if not check: for old in glob.glob(os.path.join(OUT_DIR, "*.md")): os.remove(old) for name, content, _ in agents: with open(os.path.join(OUT_DIR, f"{name}.md"), "w") as out: out.write(content) print(f"OK gen_agents: {len(agents)} concrete agents ({role_n} fan-out workers + {collapse_role_n} collapse workers + " f"{direct_role_n} direct workers + {lead_n} lead; family metadata cards=0) " f"{'validated' if check else 'written'} (profiles={len(profiles)})") if __name__ == "__main__": main()