528 lines
25 KiB
Python
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
|