175 lines
6.5 KiB
Python
175 lines
6.5 KiB
Python
#!/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()
|