#!/usr/bin/env python3 """method_contracts — Contract v2 정책 해석의 **단일 지점**(P3-B §13.1). 이 모듈은 **읽기·해석·판정 로직만** 담는다. 실제 강제(spawn 거부/전이 차단/보고서 Fail)는 호출측(context_package·subagent_register spawn gate, state_engine transition, validate_report)이 이 모듈의 판정 결과를 소비해 수행한다. 정책 로직을 여기 한 곳에 모아 3개 강제 지점의 복제를 방지한다(Global Constraint: 공용 policy engine 단일 지점). SoT/runtime 분리: `role-working-methods/`(방법론 SoT)는 읽기만. 활성화 상태는 별도 `method-contract-activations.yaml`(trusted CLI activate_method_contract.py만 write). Contract v2 판정 대상: entry["method-contract"]["version"] == 2 인 역할만. 나머지(v1 flat)는 전부 미대상 → None/[] 반환(회귀 없음). """ import hashlib import json import os import re from functools import lru_cache 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") ACTIVATIONS = os.path.join(REG, "method-contract-activations.yaml") CAP_SECTIONS = os.path.join(REG, "capability-sections.yaml") ARTIFACT_REGISTRY = os.path.join( ROOT, "org-os", "06-agent-work", "generated", "artifact-registry.yaml") @lru_cache(maxsize=1) def load_artifact_method_bindings(): """Load compiled workflow-artifact bindings (generated registry is runtime SoT).""" with open(ARTIFACT_REGISTRY, encoding="utf-8") as fh: registry = (yaml.safe_load(fh) or {}).get("artifact-registry", {}) or {} definitions = registry.get("artifact-kinds") if not isinstance(definitions, dict) or not definitions: raise RuntimeError("generated artifact registry is empty") return { kind: definition.get("method-binding") for kind, definition in definitions.items() if isinstance(definition, dict) and definition.get("method-binding") is not None } def load_role_methods(): """index.includes를 병합해 {role-id: entry}. 중복=에러(파일분리 정합).""" idx = yaml.safe_load(open(os.path.join(RWM_DIR, "index.yaml")))["role-method-contracts"] merged = {} for inc in idx["includes"]: d = yaml.safe_load(open(os.path.join(RWM_DIR, inc))) or {} for rid, e in (d.get("role-working-methods") or {}).items(): if rid in merged: raise AssertionError(f"중복 role-id {rid}") merged[rid] = e return merged def _is_v2(entry): return (entry or {}).get("method-contract", {}).get("version") == 2 def resolve_method_profile(role_id, method_id, methods=None): """(role, method) → method profile dict. v1 역할 또는 미존재 → None.""" role_id = str(role_id or "").upper() e = (methods or load_role_methods()).get(role_id) or {} if not _is_v2(e): return None # v1 flat 역할 — 계약 강제 대상 아님 for m in e.get("methods", []): if m.get("method-id") == method_id: return m return None def role_method_ids(role_id, methods=None): """역할의 v2 method-id 목록(v1 → []).""" role_id = str(role_id or "").upper() e = (methods or load_role_methods()).get(role_id) or {} return [m.get("method-id") for m in e.get("methods", [])] if _is_v2(e) else [] def load_activations(): """method-contract-activations.yaml → {role-id: {methods: {method-id: {...}}}}. 파일 부재 시 {}(계약 0개 = 전부 draft 취급 → 회귀 없음). """ if not os.path.exists(ACTIVATIONS): return {} doc = yaml.safe_load(open(ACTIVATIONS)) or {} return (doc.get("method-contract-activations") or {}).get("roles", {}) or {} def resolve_activation(role_id, method_id, activations=None): """(role, method) 활성화 레코드. 미등록 → {"status": "draft"}(기본 안전값).""" acts = activations if activations is not None else load_activations() rec = ((acts.get(str(role_id or "").upper()) or {}).get("methods") or {}).get(method_id) return rec or {"status": "draft"} def canonical_contract_hash(contract): """정규화 계약(dict)의 sha256. 키 순서 무관(sort_keys), 공백 무관(separators). hash 대상은 **계약 YAML의 정규화 JSON**(생성된 skill md가 아님) — B spec §16. """ blob = json.dumps(contract, sort_keys=True, ensure_ascii=False, separators=(",", ":")) return hashlib.sha256(blob.encode("utf-8")).hexdigest() # ---------------------------------------------------------------- migration-debt 원장 (T4.4) # draft 엣지에서 열린 이행 부채(handoff 미충족 등)를 append-only 이벤트로 기록. 정보용(게이트 # 아님) — doctor 가 미해결 부채를 surfacing 해 "아직 이행 안 끝났다"를 정직하게 보인다. def debt_ledger_path(): try: import _workspace as W return os.path.join(W.state_dir(), "method-contract-debt.jsonl") except Exception: # noqa: BLE001 — workspace 미설정 return None def _debt_key(ev): return ev.get("debt-id") or f"{ev.get('type')}:{ev.get('edge-id')}" def record_debt(event, path=None): """migration-debt 이벤트 append(status: opened|resolved 기본 opened). 실패 시 False(크래시 금지).""" p = path or debt_ledger_path() if not p: return False ev = dict(event or {}) ev.setdefault("status", "opened") try: os.makedirs(os.path.dirname(p), exist_ok=True) with open(p, "a", encoding="utf-8") as fh: fh.write(json.dumps(ev, ensure_ascii=False) + "\n") return True except Exception: # noqa: BLE001 return False def read_debt(path=None): p = path or debt_ledger_path() if not p or not os.path.exists(p): return [] out = [] try: for line in open(p, encoding="utf-8"): line = line.strip() if not line: continue try: out.append(json.loads(line)) except Exception: # noqa: BLE001 continue except Exception: # noqa: BLE001 return [] return out def unresolved_debt(path=None): """key 별 **최신** status 가 opened 인 부채 목록(resolved 로 닫힌 것 제외 — opened/resolved fold).""" latest = {} for ev in read_debt(path): if isinstance(ev, dict): latest[_debt_key(ev)] = ev return [ev for ev in latest.values() if ev.get("status") != "resolved"] def _heading_level(line): n = 0 for ch in line: if ch == "#": n += 1 else: break return n def _extract_section(md_text, heading_prefix): """헤딩(heading_prefix 로 시작)부터 다음 동급/상위 헤딩 직전까지 본문 반환(없으면 None).""" lines = md_text.splitlines() start = None for i, ln in enumerate(lines): if ln.lstrip().startswith(heading_prefix): start = i break if start is None: return None lvl = _heading_level(lines[start].lstrip()) body = [lines[start]] for ln in lines[start + 1:]: s = ln.lstrip() if s.startswith("#") and _heading_level(s) <= lvl: break body.append(ln) return "\n".join(body).rstrip() def load_capability_sections(): if not os.path.exists(CAP_SECTIONS): return {} doc = yaml.safe_load(open(CAP_SECTIONS)) or {} return (doc.get("capability-sections") or {}).get("skills", {}) or {} def resolve_capability_section(skill_id, section_id, skills=None): """(skill, section) → {heading-prefix, section-sha256, text}. 미해소(미정의·헤딩 부재) → None. section-sha256 = 헤딩~다음헤딩 본문의 sha256 — 계약의 uses-capability 참조가 실제 그 기법 절에 바인딩됐는지(method-execution capability-bindings)를 증명하는 데 쓴다. """ entry = (skills if skills is not None else load_capability_sections()).get(skill_id) if not entry: return None sec = (entry.get("sections") or {}).get(section_id) if not sec or not sec.get("heading-prefix"): return None path = os.path.join(ROOT, entry.get("skill-path", "")) if not os.path.exists(path): return None text = _extract_section(open(path, encoding="utf-8").read(), sec["heading-prefix"]) if text is None: return None return { "skill-id": skill_id, "section-id": section_id, "heading-prefix": sec["heading-prefix"], "section-sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), "text": text, } def validate_method_selection(cp, methods=None, activations=None): """context-package dict → 문제 리스트(빈=통과). standard/heavy 는 method-selection.method-id 필수(auto-infer 금지). light 는 profile이 유일하면 생략 허용, 복수면 선택 필요. v1 역할은 미적용([]). **draft 회귀 방지**: 역할에 active method 가 하나도 없으면(draft-only 계약) 강제하지 않는다([]) — 계약을 draft 로 작성하는 것만으로 기존 spawn 이 깨지지 않게(enforcement-status: draft=trace만). """ tier = cp.get("tier") or "standard" ms = cp.get("method-selection") or {} role = str(cp.get("role-id") or ms.get("role-id") or "").upper() rm = methods or load_role_methods() e = rm.get(role) or {} if not _is_v2(e): return [] # v1 역할 — 계약 미적용 acts = activations if activations is not None else load_activations() if not _active_methods(role, acts): return [] # draft-only — 아직 강제 안 함(trace/warning). active 승격 후 hard. cands = [m.get("method-id") for m in e.get("methods", [])] if not ms.get("method-id"): if tier in ("standard", "heavy"): return [f"{role}: standard/heavy 는 method-selection.method-id 필수(auto-infer 금지)"] return [] if len(cands) == 1 else [ f"{role}: light 이나 method profile 복수({len(cands)}) — method-selection 필요"] if ms["method-id"] not in cands: return [f"{role}: 미지 method-id {ms['method-id']} (후보 {cands})"] return [] def _active_methods(role, activations): role_acts = (activations.get(str(role or "").upper()) or {}).get("methods") or {} return {mid: m for mid, m in role_acts.items() if m.get("status") == "active"} def _method_active(role, method_id, activations): rec = ((activations.get(str(role or "").upper()) or {}).get("methods") or {}).get(method_id) or {} return rec.get("status") == "active" def evaluate_handoff_edge(edge, *, present, accepted, activations=None, methods=None, phase="spawn"): """handoff 엣지(profile-to-profile)를 spawn/transition 양 지점에서 동일 판정(B spec §13). hardness: producer(from)·consumer(to) profile 이 **둘 다 active** → hard(위반 시 차단). 한쪽이라도 draft → soft(비차단 warning + debt event opened) — 점진 이행. present(edge)->bool: 필수 아티팩트 실존. accepted(edge)->bool: required-state=Accepted 충족. 반환 {ok, hard, violations, debt} — ok=False 는 hard 위반(차단), soft 위반은 ok=True+debt. """ acts = activations if activations is not None else load_activations() frm, to = edge.get("from") or {}, edge.get("to") or {} hard = (_method_active(frm.get("role-id"), frm.get("method-id"), acts) and _method_active(to.get("role-id"), to.get("method-id"), acts)) violations = [] if not present(edge): violations.append(f"handoff {edge.get('edge-id')}: 필수 아티팩트({edge.get('artifact-type')}) 부재") elif edge.get("required-state") == "Accepted" and not accepted(edge): violations.append(f"handoff {edge.get('edge-id')}: 아티팩트({edge.get('artifact-type')}) 미수락(Accepted 필요)") debt = None if violations and not hard: debt = {"type": "handoff-draft-unmet", "edge-id": edge.get("edge-id"), "artifact-type": edge.get("artifact-type"), "from": frm, "to": to, "phase": phase} return {"ok": not (violations and hard), "hard": hard, "violations": violations, "debt": debt} def handoff_violations(role, method_id, *, present, accepted, activations=None, methods=None, phase="spawn"): """consumer profile 의 required-inputs 를 handoff 엣지로 평가. (blocking_errors, debts) 반환. 각 필수 입력(optional 아님)을 edge 로 만들어 evaluate_handoff_edge 로 판정 — spawn(consumer 시작 직전)·transition(stage 전이) 동일 로직. v1/미존재 profile → ([], []). """ rm = methods or load_role_methods() acts = activations if activations is not None else load_activations() role = str(role or "").upper() prof = resolve_method_profile(role, method_id, methods=rm) if not prof: return [], [] errors, debts = [], [] for inp in prof.get("required-inputs") or []: if inp.get("optional"): continue edge = { "edge-id": inp.get("edge-id") or f"input:{inp.get('artifact-type')}", "artifact-type": inp.get("artifact-type"), "from": {"role-id": inp.get("from-role"), "method-id": inp.get("from-method")}, "to": {"role-id": role, "method-id": method_id}, "required-state": inp.get("required-state"), "binding": inp.get("binding", "same-workflow"), "freshness": inp.get("freshness", "current-usable"), "cardinality": inp.get("cardinality", "1:1"), } r = evaluate_handoff_edge(edge, present=present, accepted=accepted, activations=acts, methods=rm, phase=phase) if not r["ok"]: errors.extend(r["violations"]) if r["debt"]: debts.append(r["debt"]) return errors, debts def validate_method_execution(report, activations=None, methods=None, *, enforced_tier=None, artifact_resolver=None, current_artifact=None): """보고서의 method-execution 을 active 계약(standard/heavy)에 대해 강제(B spec §11). 자기신고 금지: completed step 은 required-output 산출 시 artifact-ref 실존, skipped 는 profile skippable & 허용 skip-rule 일치. 단, 지금 제출 중인 immutable artifact는 아직 trusted registry에 없으므로 ``output-binding: current-artifact`` 로 out-of-band 바인딩한다. 중간 산출물 체크포인트는 그 산출물을 만드는 step까지만 요구하고 미래 step을 완료했다고 주장할 수 없다. contract-sha256 은 active 레코드와 바인딩(구버전 실행 차단). draft/light/v1 → 무강제([]) — 회귀 없이 점진 이행. """ rm = methods or load_role_methods() acts = activations if activations is not None else load_activations() role = str(report.get("role-id") or "").upper() e = rm.get(role) or {} if not _is_v2(e): return [] active = _active_methods(role, acts) if not active: return [] # 활성 계약 없음(draft만) — trace-only tier = enforced_tier or report.get("tier") or "standard" if tier not in ("standard", "heavy"): return [] current_kind_hint = (current_artifact or {}).get("artifact-kind") if isinstance(current_artifact, dict) else None binding = None if current_kind_hint: binding = load_artifact_method_bindings().get(current_kind_hint) binding_mode = binding.get("mode") if isinstance(binding, dict) else None if binding_mode in {"workflow-control", "stage-synthesis", "independent-review", "lens-contribution"}: # These records are workflow control, cross-worker synthesis, or independent judgment, # rather than a craft method checkpoint. Their own typed payload/references are the proof. return [] me = report.get("method-execution") if not isinstance(me, dict) or not me.get("method-id"): return [f"{role}: active 계약(tier={tier})인데 method-execution.method-id 없음 — 실행 추적 필수(자기신고 금지)."] mid = me["method-id"] if mid not in active: return [f"{role}: standard/heavy active 계약에서 미등록·draft method-id {mid!r} 선택 금지."] prof = resolve_method_profile(role, mid, methods=rm) if not prof: return [f"{role}/{mid}: active 인데 계약 profile 부재(정합 오류)."] errors = [] if str(me.get("role-id") or "").upper() != role: errors.append(f"{role}/{mid}: method-execution.role-id가 report producer role과 불일치.") if me.get("contract-sha256") != (active[mid] or {}).get("contract-sha256"): errors.append(f"{role}/{mid}: method-execution.contract-sha256 가 active 계약 hash 와 불일치(구버전 계약 실행).") workflow = list(prof.get("workflow", []) or []) current_kind = None checkpoint_index = len(workflow) - 1 aggregate_outputs = {} if isinstance(current_artifact, dict): current_kind = current_artifact.get("artifact-kind") if binding_mode == "aggregate": role_binding = (binding.get("role-methods") or {}).get(role) or {} expected_method = role_binding.get("method-id") if expected_method != mid: errors.append( f"{role}: artifact-kind={current_kind!r} aggregate는 method-id " f"{expected_method!r}에 결속됨(got {mid!r}).") return errors checkpoint = role_binding.get("checkpoint-step-id") matches = [index for index, step in enumerate(workflow) if step.get("step-id") == checkpoint] if len(matches) != 1: errors.append( f"{role}/{mid}: aggregate checkpoint-step-id={checkpoint!r} 계약 정합 오류.") return errors checkpoint_index = matches[0] aggregate_outputs = role_binding.get("embedded-outputs") or {} body = report.get("payload") if isinstance(report.get("payload"), dict) else report for step in workflow[:checkpoint_index + 1]: output = step.get("required-output") fields = aggregate_outputs.get(output) or [] if isinstance(fields, str): fields = [fields] for field in fields: if field not in body or body.get(field) in (None, "", []): errors.append( f"{role}/{mid}: aggregate required-output={output!r}를 증명하는 " f"payload.{field} 누락/빈값.") else: matches = [index for index, step in enumerate(workflow) if step.get("required-output") == current_kind] if not matches: errors.append( f"{role}/{mid}: 현재 artifact-kind={current_kind!r}를 생산하는 workflow step이 없음.") return errors if len(matches) > 1: errors.append( f"{role}/{mid}: artifact-kind={current_kind!r} checkpoint가 복수라 현재 step을 결정할 수 없음.") return errors checkpoint_index = matches[0] sr = {s.get("step-id"): s for s in (me.get("step-results") or []) if isinstance(s, dict)} known_steps = {step.get("step-id") for step in workflow} for sid in sorted(set(sr) - known_steps): errors.append(f"{role}/{mid}: 계약에 없는 step-result '{sid}'.") for index, step in enumerate(workflow): sid = step.get("step-id") res = sr.get(sid) if current_kind is not None and index > checkpoint_index: if res is not None: errors.append( f"{role}/{mid}: 현재 {current_kind} checkpoint 뒤 미래 step '{sid}' 결과를 미리 주장할 수 없음.") continue if res is None: if not step.get("skippable"): errors.append(f"{role}/{mid}: 필수 step '{sid}' 결과 누락(step-results).") continue status = res.get("status") if status == "completed": refs = res.get("artifact-refs") or [] is_current_output = current_kind is not None and ( index == checkpoint_index or binding_mode == "aggregate") binding = res.get("output-binding") if is_current_output: if binding != "current-artifact": errors.append( f"{role}/{mid}: 현재 output step '{sid}'는 output-binding=current-artifact 필수" "(자기 SHA 참조 금지).") if refs: errors.append( f"{role}/{mid}: 현재 output step '{sid}'는 artifact-ref로 자기 자신을 참조할 수 없음" "(current-artifact 바인딩 사용).") elif binding == "current-artifact": errors.append( f"{role}/{mid}: step '{sid}'는 현재 output checkpoint가 아니므로 current-artifact 바인딩 금지.") elif step.get("required-output") and not refs: errors.append(f"{role}/{mid}: step '{sid}' completed 인데 artifact-ref 없음 — 산출 증명 필요(자기신고 금지).") for index, ref in enumerate(refs): if not isinstance(ref, dict): errors.append(f"{role}/{mid}: step '{sid}' artifact-refs[{index}] object 필요.") continue report_id = str(ref.get("report-id") or "") sha = str(ref.get("sha256") or "") if not report_id or not re.fullmatch(r"[0-9a-f]{64}", sha): errors.append(f"{role}/{mid}: step '{sid}' artifact ref는 실 report-id + 64-hex sha256 필수.") continue if artifact_resolver is not None: artifact = artifact_resolver(ref) if not artifact: errors.append(f"{role}/{mid}: step '{sid}' artifact ref가 현재 workflow trusted registry에 없음: {report_id}@{sha[:12]}") continue expected_kind = step.get("required-output") if expected_kind and artifact.get("artifact-kind") != expected_kind: errors.append(f"{role}/{mid}: step '{sid}' required-output={expected_kind}, ref kind={artifact.get('artifact-kind')} 불일치.") elif status == "skipped": if not step.get("skippable"): errors.append(f"{role}/{mid}: step '{sid}' 은 skippable 아님(무단 skip).") else: allowed = {r.get("rule-id") for r in (step.get("skip-rules") or []) if isinstance(r, dict)} if allowed and res.get("skip-rule-id") not in allowed: errors.append(f"{role}/{mid}: step '{sid}' skip-rule-id {res.get('skip-rule-id')!r} 미허용(허용 {sorted(allowed)}).") else: errors.append(f"{role}/{mid}: step '{sid}' status 미지({status!r}).") if current_kind is not None and index <= checkpoint_index and status == "completed": judgments = ((step.get("completion-gates") or {}).get("judgment") or []) for gate in judgments: gate_id = gate.get("gate-id") configured_reviewer = str(gate.get("reviewer-role") or "").upper() if configured_reviewer == role: matches = [item for item in (me.get("self-check-results") or []) if item.get("step-id") == sid and item.get("gate-id") == gate_id] if not matches or matches[-1].get("verdict") != "Passed" or not matches[-1].get("evidence-refs"): errors.append( f"{role}/{mid}: gate '{gate_id}'는 producer 자기평가이므로 blocking judgment가 아니라 " "self-check-results Passed + evidence로 명시해야 함.") continue matches = [item for item in (me.get("judgment-results") or []) if item.get("step-id") == sid and item.get("gate-id") == gate_id] ref = (matches[-1].get("review-artifact-ref") if matches else None) or {} # Independent judgment is deliberately post-submit: the immutable target must # exist before another role can bind a typed review to its exact id+sha. The # acceptance mutator enforces that review before Accepted; embedding it here # would recreate the same circular dependency as current-output self refs. if ref and artifact_resolver is not None: review = artifact_resolver(ref) if not review or review.get("artifact-kind") != "method-judgment-review": errors.append( f"{role}/{mid}: judgment gate '{gate_id}' ref가 trusted method-judgment-review가 아님.") ap = prof.get("alternatives-policy") or {} min_alt = ap.get("min-alternatives") or ap.get("min") if isinstance(min_alt, int) and min_alt > 0: decisions = me.get("decisions") or [] if not decisions: errors.append(f"{role}/{mid}: alternatives-policy(min={min_alt})인데 decisions 실행 흔적 없음.") for d in decisions: alts = d.get("alternatives") or [] if len(alts) < min_alt: errors.append(f"{role}/{mid}: decision {d.get('decision-id')!r} 대안 {len(alts)}<{min_alt}(alternatives-policy).") return errors