init: company-haness 설계
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user