Files
company-haness/.claude/hooks/lens_cap.py
T

175 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()