208 lines
9.3 KiB
Python
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())
|