Files

208 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Generate .claude/skills/<role>-method/SKILL.md from role-working-methods/ (P3).
절차(How I work) 층. 카드에 인라인 embed 하던 working-method 를 역할별 method-skill 로 분리.
role-working-methods/(파일분리 SoT) = 유일 편집 원천, 이 스크립트 = 생성물(수기편집 금지).
렌더 분기:
- v1 flat(method-contract 없음): working-method/key-frameworks/evidence/sources/self-check.
- v2 contract(method-contract.version==2): 역할경계 + method profile 별 실행 계약(P3-B §10).
Phase 0 실측: 중첩 skill 미발견 → generated-dir=flat(.claude/skills), skill=<role>-method.
Usage:
python3 .claude/hooks/gen_method_skills.py # (재)생성
python3 .claude/hooks/gen_method_skills.py --check # drift 검증만(파일 안 씀, exit 1 on mismatch)
"""
import glob
import os
import shutil
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")
RWM_DIR = os.path.join(REG, "role-working-methods")
PROFILES = os.path.join(REG, "role-profiles.yaml")
REGISTRY = os.path.join(REG, "method-skill-registry.yaml")
GEN_HEADER = ("<!-- GENERATED from role-working-methods/ — do not edit. "
"Rerun: python3 .claude/hooks/gen_method_skills.py -->")
def _load(p):
return yaml.safe_load(open(p)) or {}
def _registry():
return _load(REGISTRY)["method-skill-registry"]
def _gen_dir():
return os.path.join(ROOT, _registry()["generated-dir"])
def load_role_methods():
"""role-working-methods/index.includes 병합 → {role-id: entry}. 중복/미include=에러."""
idx = _load(os.path.join(RWM_DIR, "index.yaml"))["role-method-contracts"]
merged, srcs = {}, {}
for inc in idx["includes"]:
d = _load(os.path.join(RWM_DIR, inc))
for rid, entry in (d.get("role-working-methods") or {}).items():
assert rid not in merged, f"중복 role-id {rid} ({srcs.get(rid)} & {inc})"
merged[rid] = entry
srcs[rid] = inc
return merged
def _frontmatter(rid, role_name, skill_name):
desc = (f"Use when working AS the {role_name} ({rid}) role — the step-by-step working "
f"method/contract, frameworks, and evidence for this role. "
f"Auto-loaded via the {rid.lower()} agent's skills: frontmatter.")
return ("---\n"
f"name: {skill_name}\n"
f"description: \"{desc.replace(chr(34), chr(39))}\"\n"
f"generated-from: role-working-methods/#{rid}\n"
"---\n"
f"{GEN_HEADER}\n")
def _render_v1(rid, wm, role_name):
L = [f"# {role_name} ({rid}) 실무 절차 (일하는 방식)", "", "## 절차 (working-method)"]
L += [f"- {s}" for s in (wm.get("working-method") or [])]
if wm.get("key-frameworks"):
L += ["", "## 주요 프레임워크"] + [f"- {f}" for f in wm["key-frameworks"]]
if wm.get("evidence-they-use"):
L += ["", "## 판단 근거 자료 (evidence)"] + [f"- {e}" for e in wm["evidence-they-use"]]
if wm.get("sources"):
L += ["", "## 참고 출처"] + [f"- {u}" for u in wm["sources"]]
if wm.get("self-check"): # optional, role-specific only. 공통 불변식 금지.
L += ["", "## 자기검증 (self-check) — 역할 고유 검증만"] + [f"- {c}" for c in wm["self-check"]]
return "\n".join(L)
def _render_v2(rid, entry, role_name):
"""Contract v2: 역할경계 + method profile 별 실행 계약(P3-B §10)."""
L = [f"# {role_name} ({rid}) 실무 계약 (Contract v2)"]
rb = entry.get("role-boundary") or {}
if rb:
L += ["", "## 역할 경계",
f"- owns: {', '.join(rb.get('owns', []))}",
f"- not-owns: {', '.join(rb.get('not-owns', []))}"]
for m in entry.get("methods", []):
tt = ", ".join((m.get("applies-when") or {}).get("task-types", []))
L += ["", f"## Method: {m['method-id']} (task-types: {tt})"]
if m.get("required-inputs"):
L.append("### 필수 입력")
L += [f"- {i.get('artifact-type')}{' (optional)' if i.get('optional') else ''}"
for i in m["required-inputs"]]
if m.get("workflow"):
L.append("### 워크플로")
for s in m["workflow"]:
uc = s.get("uses-capability") or {}
head = (f"- **{s['step-id']}**: {s.get('objective', '')}"
+ (f" · 기법 `{uc.get('skill-id')}#{uc.get('section-id')}`" if uc else "")
+ (f" · 산출 {s.get('required-output')}" if s.get("required-output") else "")
+ (" · skippable" if s.get("skippable") else ""))
L.append(head)
gates = s.get("completion-gates") or {}
for g in gates.get("machine", []):
L.append(f" - [machine:{g.get('enforcement', 'hard')}] {g.get('gate-id')}: "
f"{g.get('check')} {g.get('artifact', '')}.{g.get('field', '')}")
for g in gates.get("judgment", []):
L.append(f" - [judgment] {g.get('gate-id')}: {g.get('criterion', '')} "
f"(reviewer {g.get('reviewer-role', '')})")
for key, title in [("decision-rules", "판단 규칙"), ("evidence-policy", "근거 정책"),
("alternatives-policy", "대안 정책"), ("output-artifacts", "산출물"),
("prohibited-shortcuts", "금지(shortcuts)"), ("escalation-conditions", "에스컬레이션"),
("self-check", "자기검증(역할 고유)")]:
v = m.get(key)
if not v:
continue
L.append(f"### {title}")
if isinstance(v, list):
L += [f"- {x}" for x in v]
elif isinstance(v, dict):
L += [f"- {k}: {vv}" for k, vv in v.items()]
else:
L.append(f"- {v}")
if m.get("handoff-contract"):
L.append("### Handoff (profile-to-profile)")
for h in m["handoff-contract"]:
to = h.get("to") or {}
L.append(f"- {h.get('edge-id')}: -> {to.get('role-id')}/{to.get('method-id')}")
# provenance 꼬리: v1 방법론 계보(프레임워크·근거·출처)를 보존한다 — 계약이 어디서 왔는지
# 추적선. 계약 본문(role-boundary/methods)이 절차를 규정하고, 이 절은 그 근거의 출처다.
prov = [(k, t) for k, t in [("key-frameworks", "프레임워크 계보"),
("evidence-they-use", "근거 종류"),
("sources", "출처(웹조사 provenance)")] if entry.get(k)]
if prov:
L.append("")
L.append("## 참고 출처 (provenance)")
for key, title in prov:
L.append(f"### {title}")
L += [f"- {x}" for x in entry[key]]
return "\n".join(L)
def method_skill_md(rid, entry, prof, skill_name):
role_name = prof.get("role-name", rid)
is_v2 = (entry.get("method-contract") or {}).get("version") == 2
body = _render_v2(rid, entry, role_name) if is_v2 else _render_v1(rid, entry, role_name)
return _frontmatter(rid, role_name, skill_name) + "\n" + body.rstrip() + "\n"
def build_all():
"""{skill_name: content_str} for every registry role. SoT=role-working-methods/."""
entries = load_role_methods()
profiles = {p["role-id"]: p for p in _load(PROFILES)["role-profiles"]["profiles"]}
roles = _registry()["roles"]
out = {}
for rid, r in roles.items():
entry = entries.get(rid)
assert entry, f"role-working-methods/ 에 {rid} 없음(registry가 참조)"
out[r["method-skill"]] = method_skill_md(rid, entry, profiles.get(rid, {}), r["method-skill"])
return out
def main():
check = "--check" in sys.argv
out = build_all()
gen_dir = _gen_dir()
if check:
problems = []
for skill, content in out.items():
p = os.path.join(gen_dir, skill, "SKILL.md")
if not os.path.exists(p):
problems.append(f"missing: {skill}")
elif open(p).read() != content:
problems.append(f"drift: {skill}")
for d in glob.glob(os.path.join(gen_dir, "*", "SKILL.md")):
name = os.path.basename(os.path.dirname(d))
if name.endswith("-method") and name not in out:
problems.append(f"orphan: {name}")
if problems:
print("GEN-METHOD-SKILLS CHECK FAIL: %d건" % len(problems))
for p in problems:
print(f" - {p}")
return 1
print(f"OK gen_method_skills --check: {len(out)} method-skills match SoT")
return 0
# write: method-skill 디렉터리만 정리(수제 skill 보존)
for d in glob.glob(os.path.join(gen_dir, "*")):
if os.path.isdir(d) and os.path.basename(d).endswith("-method"):
shutil.rmtree(d)
for skill, content in out.items():
sd = os.path.join(gen_dir, skill)
os.makedirs(sd, exist_ok=True)
with open(os.path.join(sd, "SKILL.md"), "w") as f:
f.write(content)
print(f"OK gen_method_skills: {len(out)} method-skills written -> {gen_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())