#!/usr/bin/env python3 """Lens-cap on fan-out width — 2축 모델(lens 다양성 × sub-specialty 커버리지). (권고 #2, 리뷰 #12) 배경(리뷰 finding #12): 이전 버전은 role별이 아니라 family의 carries-lenses를 모든 멤버에 복사했다 → 같은 lens면 무조건 primary 1명만 남기고 나머지 전문분야를 삭제했다 (아키텍트 7명 EA/솔루션/앱/기술/IT/시스템분석/SWAT 전부 LENS-TECH → standard에서 6명 삭제; PM vs TPO, product vs platform design도 동일 피해). 그러나 lens 다양성과 domain/sub-specialty 커버리지는 서로 다른 축이다. 두 축: - lens(다양성 바닥, 12 lens): 무엇을 보는가. 절대 병합 금지(lens-registry R1). - sub-specialty(커버리지, lens-registry sub-specialty-axis): 어떤 전문성인가. 같은 lens라도 서로 다른 sub-specialty(application vs system architecture …)는 중복이 아니다. 위반 판정(tier != heavy): 같은 lens 안에서 (a) 같은/미분화 sub-specialty를 2명 이상 spawn(= 진짜 중복), 또는 (b) distinct sub-specialty 수가 tier 상한 초과(= fan-out 폭 가드) → 위반. distinct sub-specialty는 tier 상한까지 허용(전문분야 삭제를 멈춘다). heavy는 permissive(sub-angle 분화 전면 허용). role→sub-specialty와 tier 상한은 lens-registry.yaml의 sub-specialty-axis에서 읽는다(SoT). role-id는 대소문자 무시로 비교한다. Usage: lens_cap.py --tier light|standard|heavy --roles r1,r2,r3 exit 0 = 허용, exit 2 = 위반(같은 lens+같은 sub-specialty 중복 또는 tier 상한 초과) """ import os import sys from collections import defaultdict 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") LENS_REGISTRY = os.path.join(REG, "lens-registry.yaml") # lens-registry에 sub-specialty-axis가 없을 때의 안전한 기본값. DEFAULT_DISTINCT_CAP = {"light": 3, "standard": 8, "heavy": None} def _norm(rid): return (rid or "").strip().upper() def role_lenses(): """role-id(UPPER) -> [lens-id]. family의 carries-lenses에서 파생.""" fams = yaml.safe_load(open(FAMILIES))["capability-families"]["families"] m = {} for fam in fams: for rid in fam.get("member-role-ids") or []: m[_norm(rid)] = fam.get("carries-lenses") or [] return m def load_axis(): """(caps, role->sub-specialty) 반환. lens-registry.yaml sub-specialty-axis에서 읽는다.""" reg = yaml.safe_load(open(LENS_REGISTRY))["lens-registry"] axis = reg.get("sub-specialty-axis") or {} caps = dict(DEFAULT_DISTINCT_CAP) for tier, v in (axis.get("distinct-sub-specialties-per-lens") or {}).items(): caps[tier] = v smap = {} for rid, ss in (axis.get("role-sub-specialty") or {}).items(): if ss is not None: smap[_norm(rid)] = str(ss).strip().lower() return caps, smap def sub_specialty(rid, smap): """role의 sub-specialty. 매핑 우선, 없으면 role-id 자체(각 role=고유 전문분야), 빈값이면 미분화.""" n = _norm(rid) if n in smap: return smap[n] if n: return n.lower() return "UNDIFFERENTIATED" def evaluate(tier, roles): """(ok, info) — ok=위반 없음. info에 진단 담김.""" rl = role_lenses() caps, smap = load_axis() cap = caps.get(tier, DEFAULT_DISTINCT_CAP.get(tier)) unknown = [] # lens -> sub-specialty -> [원본 role 표기] lens_ss = defaultdict(lambda: defaultdict(list)) for r in roles: lenses = rl.get(_norm(r)) if lenses is None: unknown.append(r) continue # lens 판별 불가 → 어느 lens 그룹에도 안 넣음(위반 대상 아님) ss = sub_specialty(r, smap) for lens in lenses: lens_ss[lens][ss].append(r) dup = [] # (lens, sub-specialty, [roles]) — 같은 sub-specialty 2+ over = [] # (lens, distinct, cap, [sub-specialties]) — tier 상한 초과 for lens, ssmap in lens_ss.items(): for ss, rs in ssmap.items(): if len(rs) > 1: dup.append((lens, ss, rs)) distinct = len(ssmap) if cap is not None and distinct > cap: over.append((lens, distinct, cap, sorted(ssmap.keys()))) return (not dup and not over), { "cap": cap, "unknown": unknown, "lens_ss": lens_ss, "dup": dup, "over": over, } def main(): opt = {} a = sys.argv[1:] i = 0 while i < len(a): if a[i].startswith("--"): opt[a[i][2:]] = a[i + 1] if i + 1 < len(a) else "" i += 2 else: i += 1 tier = opt.get("tier", "standard") roles = [r.strip() for r in (opt.get("roles", "")).split(",") if r.strip()] if not roles: sys.stderr.write("usage: lens_cap.py --tier T --roles r1,r2,...\n") sys.exit(1) if tier == "heavy": print(f"[lens_cap] OK (heavy): 같은 렌즈 sub-angle 분화 전면 허용. roles={len(roles)}") sys.exit(0) ok, info = evaluate(tier, roles) if not ok: sys.stderr.write( f"[lens_cap] BUDGET(sub-specialty) 위반 (tier={tier}): " f"같은 렌즈+같은 전문분야 중복 또는 distinct 상한({info['cap']}) 초과.\n" ) for lens, ss, rs in info["dup"]: sys.stderr.write( f" - {lens}: 같은 sub-specialty '{ss}'에 워커 {len(rs)}명 " f"({', '.join(rs)}) -> 1명만 두거나 heavy tier로.\n" ) for lens, distinct, cap, sslist in info["over"]: sys.stderr.write( f" - {lens}: distinct sub-specialty {distinct}개 > 상한 {cap} " f"({', '.join(sslist)}) -> 범위를 좁히거나 heavy tier로.\n" ) if info["unknown"]: sys.stderr.write(f" (미확인 role, lens 판별 불가·무시: {', '.join(info['unknown'])})\n") sys.exit(2) nlens = len(info["lens_ss"]) ndistinct = sum(len(ssmap) for ssmap in info["lens_ss"].values()) msg = ( f"[lens_cap] OK ({tier}): 진짜 중복 없음, distinct sub-specialty 상한({info['cap']}) 이내. " f"roles={len(roles)}, lenses={nlens}, sub-specialties={ndistinct}" ) if info["unknown"]: msg += f" (미확인 role 무시: {', '.join(info['unknown'])})" print(msg) sys.exit(0) if __name__ == "__main__": main()