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

528 lines
25 KiB
Python

#!/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