#!/usr/bin/env python3 """Trusted workflow state engine. ``workflow-contracts.yaml`` owns artifact vocabulary and stage bundles. Runtime truth is append-only: ``workflow-events.jsonl``, ``artifact-events.jsonl``, ``acceptance-events.jsonl`` and ``human-signoff.jsonl``. ``workflow.yaml`` is a disposable materialized view; caller-supplied gate facts, artifact kinds, option counts and evidence grades are never trusted. The normal lifecycle is ``init-workflow -> complete-stage -> enter-stage -> ...``. Reports use the ``workflow-artifact`` envelope and are registered/reviewed as exact immutable id+sha256 snapshots. ``transition`` remains only as a guarded compatibility advance (complete + enter). """ import hashlib import json import os import re import shlex import sys import uuid from contextlib import contextmanager from datetime import datetime, timezone try: from orgos.planning.role_selector import resolve_family as _planned_resolve_family except Exception: _planned_resolve_family = None HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(os.path.dirname(HERE)) from orgos.state import event_store as _event_store # noqa: E402 from orgos.state import materializer as _materializer # noqa: E402 from orgos.state import transition_engine as _transition_engine # noqa: E402 try: import yaml except Exception: # pragma: no cover - yaml 은 저장소 전반에서 사용됨 yaml = None RULES_PATH = os.path.join(ROOT, "org-os", "00-role-registry", "state-transition-rules.yaml") PLANS_PATH = os.path.join(ROOT, "org-os", "06-agent-work", "execution-plans.yaml") CMAP_PATH = os.path.join(ROOT, "org-os", "06-agent-work", "collaboration-map.yaml") TIERS_PATH = os.path.join(ROOT, "org-os", "06-agent-work", "governance-tiers.yaml") CONTRACTS_PATH = os.path.join(ROOT, "org-os", "06-agent-work", "workflow-contracts.yaml") ROLES_PATH = os.path.join(ROOT, "org-os", "00-role-registry", "roles.yaml") FAMILIES_PATH = os.path.join(ROOT, "org-os", "00-role-registry", "capability-families.yaml") INITIAL_STAGE = "intake" DEFAULT_PLAN = "cascade" DEFAULT_TIER = "standard" DEFAULT_MODE = "converge" VALID_TIERS = ("light", "standard", "heavy") VALID_MODES = ("converge", "divergent") EVIDENCE_CONTRACT_VERSION = 2 def _log(msg): try: sys.stderr.write(f"[state_engine] {msg}\n") except Exception: pass def _now(): return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _stamp(): return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") # ---------------------------------------------------------------- SSOT loaders def _load_yaml(path): if not yaml or not os.path.exists(path): return {} try: with open(path, encoding="utf-8") as fh: data = yaml.safe_load(fh) return data if isinstance(data, dict) else {} except Exception as e: _log(f"YAML 로드 실패({os.path.basename(path)}): {e}") return {} def load_rules(): return _load_yaml(RULES_PATH).get("state-transition-rules", {}) or {} def load_plans(): return _load_yaml(PLANS_PATH).get("execution-plans", {}) or {} def load_cmap(): return _load_yaml(CMAP_PATH).get("collaboration-map", {}) or {} def load_tiers(): return _load_yaml(TIERS_PATH).get("governance-tiers", {}) or {} def load_contracts(): return _load_yaml(CONTRACTS_PATH).get("workflow-contracts", {}) or {} def _role_registry(): """Concrete role registry. Placeholder strings and invented roles never enter it.""" roles = {} try: registry = _load_yaml(ROLES_PATH).get("role-registry", {}) or {} for role in registry.get("roles", []) or []: if isinstance(role, dict) and role.get("role-id"): roles[str(role["role-id"])] = role except Exception: pass roles["HUMAN-001"] = { "role-id": "HUMAN-001", "role-type": "human", "is-decision-maker": True, } return roles def _role_has_capability(role_id, capability): allowed = (load_contracts().get("role-capabilities", {}) or {}).get(capability, []) or [] return str(role_id or "").upper() in {str(value).upper() for value in allowed} def resolve_family(family_id, signals=None): """Resolve family metadata to concrete workers. Family is metadata, never an actor. The executable planner treats its members as a candidate pool and returns the minimum sufficient concrete set. The local implementation below is only a compatibility fallback for incomplete installs. """ if _planned_resolve_family is not None: try: planned = _planned_resolve_family(family_id, signals=signals, tier="standard") if planned: return planned except Exception as exc: _log(f"role planner fallback for {family_id}: {exc}") signal_set = {str(value).strip().lower() for value in (signals or []) if str(value).strip()} families = (_load_yaml(FAMILIES_PATH).get("capability-families", {}) or {}).get("families", []) or [] for family in families: if isinstance(family, dict) and family.get("family-id") == family_id: members = [role for role in family.get("member-role-ids", []) or [] if role in _role_registry()] collaboration = family.get("collaboration-default") if collaboration == "collapse": primary = family.get("primary-role-id") reason = "default-primary" for route in family.get("collapse-routes", []) or []: route_signals = {str(value).strip().lower() for value in route.get("when-any", []) or []} if signal_set.intersection(route_signals): primary = route.get("role-id") reason = "matched:" + ",".join(sorted(signal_set.intersection(route_signals))) break if primary not in members: return None return { "requested-family": family_id, "resolved-workers": [primary], "primary-worker": primary, "available-workers": members, "routing-reason": reason, "collaboration-default": collaboration, } primary = members[0] if members else None if not primary: return None return { "requested-family": family_id, "resolved-workers": [primary], "primary-worker": primary, "available-workers": members, "routing-reason": "compatibility-minimum-primary", "collaboration-default": collaboration, } return None def _ws_transitions(): """Derive runtime transitions from workflow-contracts.yaml (single contract SSOT).""" try: contract = load_contracts() transitions = [] for workflow in (contract.get("workflows", {}) or {}).values(): if not isinstance(workflow, dict): continue stages = workflow.get("stages", {}) or {} for stage, definition in stages.items(): if not isinstance(definition, dict) or not definition.get("next"): continue destinations = definition.get("next") if not isinstance(destinations, list): destinations = [destinations] for destination in destinations: transitions.append({ "from": stage, "to": destination, "allowed-by": definition.get("actor") or {"executor": ["OPS-ORCH"]}, "required-conditions": list(definition.get("exit-gate") or []), }) transitions.extend(workflow.get("additional-transitions") or []) transitions.extend(contract.get("side-transitions") or []) return transitions except Exception: return [] def _plan_stages(plan): try: contract_plan = (load_contracts().get("workflows", {}) or {}).get(plan, {}) or {} if isinstance(contract_plan.get("stages"), dict): return list(contract_plan["stages"].keys()) p = (load_plans().get("plans", {}) or {}).get(plan, {}) return list(p.get("stages", []) or []) except Exception: return [] def _initial_stage(plan): """plan 의 첫 stage. namespaced plan(design-direction)은 자기 첫 stage 에서 시작.""" stages = _plan_stages(plan) return stages[0] if stages else INITIAL_STAGE def _plan_terminal(plan): """plan 의 종단 stage(execution-plans terminal-stage). 없으면 None.""" try: contract_plan = (load_contracts().get("workflows", {}) or {}).get(plan, {}) or {} if contract_plan.get("terminal-stage"): return contract_plan.get("terminal-stage") p = (load_plans().get("plans", {}) or {}).get(plan, {}) return p.get("terminal-stage") except Exception: return None def _must_read_map(): """{build-family: [must-read-design, ...]} — collaboration-map design-to-build-contract.""" out = {} try: for m in load_cmap().get("design-to-build-contract", {}).get("mappings", []) or []: fam = m.get("build-family") if fam: out[fam] = list(m.get("must-read-designs", []) or []) except Exception: pass return out _EGRADE = {"E0": 0, "E1": 1, "E2": 2, "E3": 3, "E4": 4, "E5": 5} def _tier_evidence_min(tier): """tier 최소 증거등급('E2'..). governance-tiers 우선, state-transition-rules 폴백.""" try: t = (load_tiers().get("tiers", {}) or {}).get(tier, {}) v = (t.get("converge", {}) or {}).get("evidence-grade-min") if v: return v except Exception: pass try: tm = load_rules().get("tier-modifiers", {}) or {} return (tm.get(tier, {}) or {}).get("evidence-grade-min") except Exception: return None def _tier_human_gate_required(tier): """acceptance→released 에서 사람 승인이 필수인 tier 인가(heavy).""" return str(tier) == "heavy" def _derived_risk_tier(profile): """Apply governance-tiers risk/reversibility/blast rules deterministically.""" risk = profile.get("risk") if isinstance(profile, dict) else {} order = {"light": 0, "standard": 1, "heavy": 2} base = {"Low": 0, "Med": 1, "High": 2, "Critical": 2}.get(risk.get("risk-level"), 1) if risk.get("reversibility") == "one-way-door": base += 1 if risk.get("blast-radius") == "cross-team": base += 1 if risk.get("blast-radius") == "production-customer-revenue": base = 2 if any(risk.get(key) for key in ( "security-bearing", "data-migration", "external-side-effect", "privacy", "regulatory", "slo-impact", "pii", "data-residency")): base = max(base, order["standard"]) return VALID_TIERS[min(base, 2)] # ---------------------------------------------------------------- ledger I/O def _state_dir(create=False): try: import _workspace as W # noqa: E402 sd = W.state_dir() except Exception as e: # WorkspaceNotSetError 포함 _log(f"workspace 미해석 — 원장 접근 스킵: {e}") return None if create: try: os.makedirs(sd, exist_ok=True) except Exception as e: _log(f"state_dir 생성 실패: {e}") return None return sd def _wf_dir(wf, create=False): sd = _state_dir(create=create) if not sd: return None d = os.path.join(sd, wf) if create: try: os.makedirs(d, exist_ok=True) except Exception as e: _log(f"wf-dir 생성 실패: {e}") return None return d def _ledger_path(wf, create=False): d = _wf_dir(wf, create=create) return os.path.join(d, "workflow.yaml") if d else None def _events_path(wf, create=False): d = _wf_dir(wf, create=create) return os.path.join(d, "state-events.jsonl") if d else None def _workflow_events_path(wf, create=False): d = _wf_dir(wf, create=create) return os.path.join(d, "workflow-events.jsonl") if d else None def _artifact_events_path(create=False): sd = _state_dir(create=create) return os.path.join(sd, "artifact-events.jsonl") if sd else None def _read_jsonl(path): return _event_store.read_jsonl(path, on_error=_log) @contextmanager def _workflow_lock(wf): """Serialize event append + projection writes for a workflow.""" d = _wf_dir(wf, create=True) if not d: raise OSError("workspace 미설정") lock_path = os.path.join(d, ".workflow.lock") with open(lock_path, "a+", encoding="utf-8") as lock: try: import fcntl fcntl.flock(lock.fileno(), fcntl.LOCK_EX) except Exception: pass try: yield finally: try: import fcntl fcntl.flock(lock.fileno(), fcntl.LOCK_UN) except Exception: pass def _default_ledger(wf, plan=DEFAULT_PLAN, tier=DEFAULT_TIER, mode=DEFAULT_MODE): return { "workflow-id": wf, "stage": _initial_stage(plan), "stage-status": "running", "last-completed-stage": None, "completed-for-next-stage": None, "plan": plan, "tier": tier, "mode": mode, "evidence-contract-version": EVIDENCE_CONTRACT_VERSION, "artifacts": [], "progress": {}, "created-at": _now(), "last-updated-at": _now(), } def read_ledger(wf): """원장 dict 또는 None(부재/파싱불가). 예외를 던지지 않는다.""" p = _ledger_path(wf, create=False) if not p or not os.path.exists(p): return None try: with open(p, encoding="utf-8") as fh: data = yaml.safe_load(fh) if yaml else None return _project_ledger(wf, data) if isinstance(data, dict) else None except Exception as e: _log(f"원장 읽기 실패({wf}): {e}") return None def _load_ledger_safe(wf): """원장이 없으면 기본 원장(stage=intake)을 메모리로 반환(파일 생성 안 함).""" return read_ledger(wf) or _default_ledger(wf) def _write_ledger(wf, data): p = _ledger_path(wf, create=True) if not p: return False try: # atomic write(temp + os.replace): 병렬 agent 가 원장을 동시에 읽을 때 torn/partial # read 를 막는다(재리뷰 지적). POSIX 에서 os.replace 는 원자적 rename. tmp = f"{p}.tmp.{os.getpid()}" with open(tmp, "w", encoding="utf-8") as fh: yaml.safe_dump(data, fh, allow_unicode=True, sort_keys=False) os.replace(tmp, p) return True except Exception as e: _log(f"원장 기록 실패({wf}): {e}") try: if os.path.exists(tmp): os.remove(tmp) except Exception: pass return False def init_ledger(wf, plan=DEFAULT_PLAN, tier=DEFAULT_TIER, mode=DEFAULT_MODE, overwrite=False, parent_workflow=None, product_decision=None, direction_input_brief=None): """워크플로 원장을 생성(stage=intake). 이미 있으면 overwrite=False 시 그대로 반환. plan == "design-direction" 인 경우에만: 순환 정합을 위해 부모 workflow(product-decision) 로 반드시 바인딩해야 한다 — parent_workflow/product_decision/direction_input_brief 3종 모두 필수, 부모 원장 **실존**을 파일 존재로 직접 검증(주의: `_load_ledger_safe` 는 원장이 없어도 기본 원장을 메모리로 반환하므로, 그 반환값의 진위 여부만으로는 부재를 구분할 수 없다 — 그래서 여기선 그 함수를 쓰지 않고 `_ledger_path` + `os.path.exists` 로 먼저 실존을 확인한 다음에만 로드한다), 그리고 부모에 **정확히 이 product_decision** 이 accepted 된 적이 있는지 `_al_accepted_ids(parent)` 로 검증한다(부모가 다른 무언가를 accepted 했다는 사실만으로는 통과하지 않으며, 원장 문자열 부분일치 fallback 도 없다 — 위조 id·substring 우회 모두 차단, 재리뷰 Critical fix). direction-input-brief 는 부모가 S1 에서 이미 freeze 했어야 하므로 그 경로가 실존하지 않으면 즉시 거부한다. 다른 plan 은 기존 동작 그대로.""" if tier not in VALID_TIERS: raise ValueError(f"tier는 {VALID_TIERS} 중 하나여야 한다(got {tier!r})") if mode not in VALID_MODES: raise ValueError(f"mode는 {VALID_MODES} 중 하나여야 한다(got {mode!r})") if plan not in (load_contracts().get("workflows", {}) or {}): raise ValueError(f"등록되지 않은 workflow plan: {plan}") existing = read_ledger(wf) if existing and not overwrite: return existing led = _default_ledger(wf, plan, tier, mode) if plan in ("design-direction", "experience-foundation"): required = "--parent-workflow --product-decision" if plan == "design-direction": required += " --direction-input-brief" if not (parent_workflow and product_decision) or ( plan == "design-direction" and not direction_input_brief): raise ValueError( f"{plan}: {required} 필수" ) parent_path = _ledger_path(parent_workflow, create=False) if not parent_path or not os.path.exists(parent_path): raise ValueError(f"{plan}: 부모 workflow '{parent_workflow}' 원장 없음") # finding(Critical, re-review): _al_has_accepted(parent) 는 부모가 "아무 report 나" accepted # 했으면 True 였다(이 product_decision 자체를 검증하지 않음) — 위조 id 로 편승 가능했다. # 그리고 substring fallback(`product_decision not in str(parent_led)`)은 원장의 고정 스키마 # 키("workflow-id" 등) 때문에 사실상 항상 통과했다(예: "workflow" 는 어떤 부모에도 매치). # 이제 부모의 **실제 accepted report-id 집합**(_al_accepted_ids)에 이 product_decision 이 # 정확히 있는지만으로 검증한다 — substring fallback 은 완전히 제거. parent_arts = (_load_ledger_safe(parent_workflow).get("artifacts") or []) if product_decision not in _al_accepted_ids(parent_workflow, parent_arts): raise ValueError( f"{plan}: 부모 '{parent_workflow}'에 accepted product-decision '{product_decision}' 없음" ) led["parent-workflow-id"] = parent_workflow led["product-decision-id"] = product_decision if (plan == "experience-foundation" and tier == "light" and _experience_foundation_required(parent_workflow)): raise ValueError( "experience-foundation: 공개 웹/신규 제품/대규모 리디자인은 tier standard 이상 필수" ) if plan == "design-direction": parent_led = _load_ledger_safe(parent_workflow) if _experience_foundation_required(parent_workflow) and not _has_experience_foundation( parent_workflow, parent_led): raise ValueError( "design-direction: 공개 웹/신규 제품/대규모 리디자인은 approved experience-foundation 선행 필수" ) led["direction-input-brief-ref"] = direction_input_brief # finding(Minor): direction-input-brief 는 S1 에서 부모가 이미 freeze 한 것이어야 한다 — # 자식 init 시점에 파일이 없으면 sha 없이 조용히 바인딩하지 말고 즉시 거부한다. _brief_path = ( direction_input_brief if os.path.isabs(direction_input_brief) else os.path.join(ROOT, direction_input_brief) ) if not os.path.exists(_brief_path): raise ValueError(f"design-direction: direction-input-brief 파일 없음: {direction_input_brief}") if (_experience_foundation_required(parent_workflow) and not _direction_brief_foundation_refs_ok(parent_workflow, direction_input_brief)): raise ValueError( "design-direction: direction-input-brief의 benchmark/blueprint/wireframe exact ref+SHA가 " "부모 approved experience-foundation과 불일치" ) brief_sha = _current_input_brief_sha(led) if brief_sha: led["direction-input-brief-sha256"] = brief_sha if not _write_ledger(wf, led): raise OSError(f"workflow projection 생성 실패: {wf}") init_event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "workflow-initialized", "workflow-id": wf, "stage": led.get("stage"), "plan": plan, "tier": tier, "mode": mode, "evidence-contract-version": EVIDENCE_CONTRACT_VERSION, "actor": "OPS-ORCH", "effective-at": _now(), } for key in ("parent-workflow-id", "product-decision-id", "direction-input-brief-ref", "direction-input-brief-sha256"): if led.get(key) is not None: init_event[key] = led.get(key) committed, error = _atomic_event_transaction(wf, workflow_event=init_event) if not committed: raise OSError(error or f"workflow init event 기록 실패: {wf}") return read_ledger(wf) or led def find_child_direction_workflow(parent_workflow, product_decision, brief_sha): """(parent_workflow, product_decision) 로 바인딩된 design-direction 자식 workflow 를 찾는다(dedup — 같은 product-decision 에 대해 새 design-direction 사이클을 중복으로 열지 않기 위함). 없으면 None. 찾으면 {"workflow-id", "stage", "stale"} — stale 은 자식이 바인딩 당시 읽은 input-brief 해시가 호출측이 지금 들고 있는 brief_sha 와 다른지(즉 그 사이 input-brief 가 바뀌었는지)를 알려준다.""" base = _state_dir(create=False) if not base or not os.path.isdir(base): return None for wf in os.listdir(base): led = _load_ledger_safe(wf) if led.get("plan") != "design-direction": continue if led.get("parent-workflow-id") == parent_workflow and led.get("product-decision-id") == product_decision: return { "workflow-id": wf, "stage": led.get("stage"), "stale": led.get("direction-input-brief-sha256") != brief_sha, } return None def find_child_experience_workflow(parent_workflow, product_decision): """Return the single experience-foundation child bound to a parent decision.""" base = _state_dir(create=False) if not base or not os.path.isdir(base): return None for wf in os.listdir(base): led = _load_ledger_safe(wf) if (led.get("plan") == "experience-foundation" and led.get("parent-workflow-id") == parent_workflow and led.get("product-decision-id") == product_decision): return {"workflow-id": wf, "stage": led.get("stage")} return None def init_workflow(wf, **kwargs): """Canonical API name for workflow initialization.""" return init_ledger(wf, **kwargs) def _append_state_event(wf, event): """Append a canonical workflow event. workflow.yaml is only its projection.""" p = _workflow_events_path(wf, create=True) return _event_store.append_jsonl(p, event, on_error=_log) def _append_artifact_event(event): p = _artifact_events_path(create=True) # artifact-events.jsonl is global, so it needs its own file lock. return _event_store.append_jsonl(p, event, file_lock=True, on_error=_log) def read_workflow_events(wf): return _read_jsonl(_workflow_events_path(wf, create=False)) def read_artifact_events(wf=None): events = _read_jsonl(_artifact_events_path(create=False)) if wf is None: return events return [event for event in events if event.get("workflow-id") == wf] def _stage_epoch(wf, stage=None): """Return the append-only event that opened the current/requested stage.""" current = None for event in read_workflow_events(wf): if event.get("event-type") == "workflow-initialized": current = { "event-id": event.get("workflow-event-id"), "stage": event.get("stage") or _initial_stage(event.get("plan", DEFAULT_PLAN)), "effective-at": event.get("effective-at"), } elif event.get("event-type") == "state-transition": current = { "event-id": event.get("state-event-id") or event.get("workflow-event-id"), "stage": event.get("to"), "effective-at": event.get("effective-at"), } if current and (stage is None or current.get("stage") == stage): return current return None def _latest_artifact_of_kind(artifacts, kind): return next((artifact for artifact in reversed(artifacts or []) if isinstance(artifact, dict) and artifact.get("artifact-kind") == kind), None) def _event_artifact_valid(event): if event.get("event-type") != "artifact-submitted": return False path = event.get("path") try: import artifact_contract as AC ap = AC.absolute_path(path) return bool(ap and os.path.isfile(ap) and AC.sha256_file(ap) == event.get("artifact-sha256")) except Exception: return False def _trusted_artifacts(wf): """Active immutable artifact revisions reconstructed from canonical events.""" active = {} order = [] for event in read_artifact_events(wf): if event.get("event-type") != "artifact-submitted": continue artifact_id = event.get("artifact-id") if not artifact_id: continue if artifact_id not in active: order.append(artifact_id) active[artifact_id] = dict(event) return [active[artifact_id] for artifact_id in order if _event_artifact_valid(active[artifact_id])] def _project_ledger(wf, ledger): """Rebuild protected materialized fields from append-only events on every read.""" return _materializer.project_workflow( ledger, trusted_artifacts=_trusted_artifacts(wf), workflow_events=read_workflow_events(wf), initial_stage=_initial_stage, quality_panel_unmet=_quality_panel_unmet, default_plan=DEFAULT_PLAN, ) # ---------------------------------------------------------------- human signoff (P0-4) def _human_signoff_path(wf, create=False): d = _wf_dir(wf, create=create) return os.path.join(d, "human-signoff.jsonl") if d else None def _has_human_signoff(wf, stage): """guard 보호 signoff 파일에 이 stage(또는 '*')에 대한 사람(HUMAN-*) 승인 항목이 있으면 True. finding P0-4: human-gate 를 원장 자기신고 플래그가 아니라 이 파일로 파생한다. guard_tools 가 에이전트의 이 파일 쓰기와 `state_engine.py signoff` 호출을 모두 막으므로, 위조하려면 guard 를 우회해야 한다(문서화된 soft-boundary — 하네스는 사람을 인증할 수 없다).""" p = _human_signoff_path(wf, create=False) if not p or not os.path.exists(p): return False try: with open(p, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: ev = json.loads(line) except Exception: continue if not isinstance(ev, dict): continue by = str(ev.get("by") or "") st = str(ev.get("stage") or "") role = _role_registry().get(by) if (role and role.get("role-type") == "human" and ev.get("workflow-id") == wf and (st == str(stage) or st == "*")): return True except Exception: return False return False def _human_role(actor_id): role = _role_registry().get(str(actor_id or "")) or {} return role.get("role-type") == "human" def _has_exact_human_approval(wf, stage): """Return whether the gate's canonical decision artifact was accepted by a human. A decision-packet acceptance and a release decision are already typed, immutable, id+sha-bound human decisions. Requiring a second stage signoff for the same decision creates duplicate authority and contradictory audit histories. """ trusted = _trusted_artifacts(wf) if stage == "decide": packet = _latest_artifact_of_kind(trusted, "executive-decision-packet") if not packet: return False try: import acceptance_log as AL for event in reversed(AL.read_events()): if (event.get("workflow-id") != wf or event.get("report-id") != packet.get("artifact-id") or event.get("artifact-sha256") != packet.get("artifact-sha256")): continue reviewer = event.get("reviewer") or {} reviewer_id = reviewer.get("actor-id") or reviewer.get("role-id") or event.get("role-id") return event.get("decision") == "accepted" and _human_role(reviewer_id) except Exception: return False return False if stage == "acceptance": trusted_revisions = { (item.get("artifact-id"), item.get("artifact-sha256")) for item in trusted if item.get("artifact-kind") == "release-decision" } for event in reversed(read_workflow_events(wf)): if event.get("event-type") != "release-decision-recorded": continue revision = (event.get("decision-artifact-id"), event.get("decision-artifact-sha256")) if revision not in trusted_revisions: continue return event.get("status") == "Approved" and _human_role(event.get("actor")) return False def _human_gate_satisfied(wf, stage): """Canonical exact approval first; legacy stage signoff remains a narrow fallback.""" return _has_exact_human_approval(wf, stage) or _has_human_signoff(wf, stage) def record_signoff(wf, stage, by): """사람 승인(human-signoff)을 append. **사람이 세션 밖에서** 호출해야 한다 — guard_tools 가 에이전트의 이 CLI 호출을 차단한다(P0-4 soft-boundary). by 는 HUMAN-* 여야 유효.""" human = _role_registry().get(str(by or "")) if not human or human.get("role-type") != "human": return False, "signoff --by 는 등록된 human role이어야 한다." if read_ledger(wf) is None: return False, f"workflow 원장 없음: {wf}" p = _human_signoff_path(wf, create=True) if not p: return False, "workspace 미설정 — signoff 기록 불가." try: with _workflow_lock(wf), open(p, "a", encoding="utf-8") as fh: fh.write(json.dumps({ "human-signoff-event-id": f"hse-{_stamp()}-{uuid.uuid4().hex[:8]}", "workflow-id": wf, "stage": stage, "by": by, "at": _now(), }, ensure_ascii=False) + "\n") fh.flush() os.fsync(fh.fileno()) return True, None except Exception as e: return False, f"signoff 기록 실패: {e}" def record_human_signoff(wf, stage, by): """Canonical API name; authentication is enforced by the human-only CLI boundary.""" return record_signoff(wf, stage, by) def _prepare_submit_event(wf, report_path, actor): ledger = read_ledger(wf) if ledger is None: return False, f"workflow 원장 없음: {wf} (init-workflow 먼저 실행)" roles = _role_registry() actor_id = str(actor or "").strip() if actor_id not in roles: return False, f"등록되지 않은 submit actor: {actor_id}" try: import artifact_contract as AC artifact = AC.validate_snapshot(report_path, expected_workflow=wf) except Exception as exc: return False, str(exc) producer = artifact.get("producer-role-id") if producer not in roles: return False, f"등록되지 않은 producer-role-id: {producer}" if not AC.producer_allowed(artifact.get("artifact-kind"), producer): return False, f"producer '{producer}'는 artifact-kind={artifact.get('artifact-kind')} 생산 권한이 없다" if actor_id != producer and not _role_has_capability(actor_id, "transition-executor"): return False, f"submit actor '{actor_id}'는 producer '{producer}'도 transition-executor도 아니다" try: import method_contracts as MC _ap, raw_report = AC.load_report(report_path) raw_report = dict(raw_report) raw_report.setdefault("role-id", producer) raw_report.setdefault("workflow-id", wf) declared_tier = raw_report.get("tier") if declared_tier is not None and declared_tier != ledger.get("tier"): return False, (f"report tier가 canonical workflow tier와 불일치" f"(report={declared_tier}, workflow={ledger.get('tier')})") trusted = _trusted_artifacts(wf) def _resolve_method_ref(ref): return next((item for item in trusted if item.get("artifact-id") == ref.get("report-id") and item.get("artifact-sha256") == ref.get("sha256")), None) method_errors = MC.validate_method_execution( raw_report, enforced_tier=ledger.get("tier"), artifact_resolver=_resolve_method_ref, current_artifact=artifact) if method_errors: return False, "method execution 계약 위반:\n - " + "\n - ".join(method_errors) except Exception as exc: if ledger.get("tier") in ("standard", "heavy"): return False, f"method execution policy 평가 실패(fail-closed): {exc}" body = artifact.get("payload") or {} artifact_definition = (AC.load_contract().get("artifact-kinds", {}) or {}).get( artifact.get("artifact-kind"), {}) or {} method_binding = artifact_definition.get("method-binding") or {} if method_binding.get("mode") == "stage-synthesis": source_refs = body.get("source-artifact-refs") or [] seen_source_refs = set() for index, ref in enumerate(source_refs): if not isinstance(ref, dict): return False, f"stage-synthesis source-artifact-refs[{index}] object 필요" key = (str(ref.get("artifact-id") or ""), str(ref.get("artifact-sha256") or "")) if not key[0] or not re.fullmatch(r"[0-9a-f]{64}", key[1]): return False, (f"stage-synthesis source-artifact-refs[{index}]는 " "artifact-id + 64-hex artifact-sha256 필수") if key in seen_source_refs: return False, "stage-synthesis source-artifact-refs 중복" seen_source_refs.add(key) if not any(item.get("artifact-id") == key[0] and item.get("artifact-sha256") == key[1] for item in trusted): return False, ("stage-synthesis source artifact가 현재 workflow trusted registry에 없음: " f"{key[0]}@{key[1][:12]}") basis_id = body.get("basis-artifact-id") basis_sha = body.get("basis-artifact-sha256") if basis_id is not None or basis_sha is not None: if not (basis_id and basis_sha): return False, "basis artifact binding은 id와 sha256을 함께 선언해야 한다" if not any(item.get("artifact-id") == basis_id and item.get("artifact-sha256") == basis_sha for item in trusted): return False, "basis artifact id+sha256가 현재 workflow의 trusted revision과 불일치" if artifact.get("artifact-kind") == "compatibility-review": if str(body.get("reviewer-role-id") or "") != producer: return False, "compatibility-review reviewer-role-id는 artifact producer와 같아야 한다" endpoint_producers = set() for side in ("left", "right"): endpoint = body.get(side) or {} target = next((item for item in trusted if item.get("artifact-kind") == endpoint.get("artifact-kind") and item.get("artifact-id") == endpoint.get("artifact-id") and item.get("artifact-sha256") == endpoint.get("artifact-sha256")), None) if not target: return False, f"compatibility-review {side} exact trusted artifact binding 불일치" endpoint_producers.add(str(target.get("producer-role-id") or "")) if producer in endpoint_producers: return False, "compatibility-review는 양쪽 산출물 producer와 독립이어야 한다" if artifact.get("artifact-kind") == "method-judgment-review": if str(body.get("reviewer-role-id") or "") != producer: return False, "method-judgment-review reviewer-role-id는 artifact producer와 같아야 한다" target = next((item for item in trusted if item.get("artifact-id") == body.get("reviewed-artifact-id") and item.get("artifact-sha256") == body.get("reviewed-artifact-sha256")), None) if not target: return False, "method-judgment-review reviewed artifact exact id+sha binding 불일치" if producer == target.get("producer-role-id"): return False, "method-judgment-review self-review 금지" if artifact.get("artifact-kind") == "decision-brief" and body.get("tier") != ledger.get("tier"): return False, "decision-brief.payload.tier는 canonical workflow tier와 같아야 한다" if artifact.get("artifact-kind") == "workload-profile": required_tier = _derived_risk_tier(body) if ledger.get("tier") not in VALID_TIERS: return False, f"canonical workflow tier가 미등록 값이다: {ledger.get('tier')!r}" if VALID_TIERS.index(ledger.get("tier")) < VALID_TIERS.index(required_tier): return False, (f"workflow tier가 workload risk hard floor보다 낮다" f"(workflow={ledger.get('tier')}, required={required_tier})") current_stage = ledger.get("stage") artifact_stage = artifact.get("stage") allowed_stages = {current_stage} if artifact.get("artifact-kind") == "blocked-report": allowed_stages.add("blocked") if artifact_stage not in allowed_stages: return False, (f"artifact stage는 현재 running stage와 같아야 한다" f"(current={current_stage}, report={artifact_stage})") ap = AC.absolute_path(report_path) base = os.path.basename(ap) if base.endswith(".report.yaml"): file_id = base[:-len(".report.yaml")] if file_id != artifact.get("artifact-id"): return False, f"report-id/파일명 불일치: {artifact.get('artifact-id')} != {file_id}" existing = [event for event in read_artifact_events(wf) if event.get("artifact-id") == artifact.get("artifact-id")] if existing: if existing[-1].get("artifact-sha256") == artifact.get("artifact-sha256"): return True, {"event": existing[-1], "existing": True, "artifact": artifact} return False, f"불변 artifact-id 재사용 거부: {artifact.get('artifact-id')} (새 revision은 새 id 필요)" event = { "artifact-event-id": f"afe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "artifact-submitted", "effective-at": _now(), "submitted-by": actor_id, **{key: value for key, value in artifact.items() if key != "payload"}, } epoch = _stage_epoch(wf, current_stage) if epoch: event["stage-epoch-id"] = epoch.get("event-id") event["stage-entered-at"] = epoch.get("effective-at") body = artifact.get("payload") or {} profile = body if artifact.get("artifact-kind") == "workload-profile" else body.get("workload-profile") if isinstance(profile, dict): event["workload-profile"] = profile for key in ("basis-artifact-id", "basis-artifact-sha256"): if body.get(key) is not None: event[key] = body.get(key) return True, {"event": event, "existing": False, "artifact": artifact} def _atomic_event_transaction(wf, artifact_event=None, workflow_event=None): """Atomically append the event pair; roll back both JSONL tails on failure.""" paths = [] if artifact_event: paths.append((_artifact_events_path(create=True), artifact_event)) if workflow_event: paths.append((_workflow_events_path(wf, create=True), workflow_event)) try: _event_store.atomic_append(paths, transaction_lock=_workflow_lock(wf)) with _workflow_lock(wf): led = read_ledger(wf) or _default_ledger(wf) led["artifacts"] = _trusted_artifacts(wf) led["last-updated-at"] = _now() _write_ledger(wf, led) return True, None except Exception as exc: return False, f"event transaction 실패: {exc}" def submit_report(wf, report_path, actor): """Validate and atomically register an immutable report snapshot.""" ok, prepared = _prepare_submit_event(wf, report_path, actor) if not ok: return False, prepared event = prepared["event"] if prepared["existing"]: return True, event ok, error = _atomic_event_transaction(wf, artifact_event=event) return (True, event) if ok else (False, error) def submit_artifact(wf, report_path, actor): """Canonical API name; ``submit_report`` remains a compatibility alias.""" return submit_report(wf, report_path, actor) def record_artifact(wf, design_type=None, report_id=None, path=None, option_count=None, evidence_grade=None): """Removed unsafe compatibility API. Callers cannot provide derived gate fields. Kept only so old imports fail explicitly instead of silently minting a trusted artifact. """ _log("record_artifact 제거됨: submit_report(wf, report_path, actor)를 사용하라") return False, read_ledger(wf) or _default_ledger(wf) def _method_judgment_unmet(wf, artifact): """Independent method judgments are post-submit, pre-acceptance exact reviews.""" if artifact.get("artifact-kind") in {"method-judgment-review", "compatibility-review", "quality-gate-review"}: return [] ledger = read_ledger(wf) or {} if ledger.get("tier") not in ("standard", "heavy"): return [] try: import artifact_contract as AC import method_contracts as MC _path, raw = AC.load_report(artifact.get("path")) producer = str(artifact.get("producer-role-id") or "").upper() execution = raw.get("method-execution") or {} profile = MC.resolve_method_profile(producer, execution.get("method-id")) if not profile: return [] completed = {item.get("step-id") for item in execution.get("step-results", []) or [] if item.get("status") == "completed"} requirements = [] for step in profile.get("workflow", []) or []: if step.get("step-id") not in completed: continue for gate in ((step.get("completion-gates") or {}).get("judgment") or []): reviewer = str(gate.get("reviewer-role") or "").upper() if reviewer and reviewer != producer: requirements.append((step.get("step-id"), gate.get("gate-id"), reviewer)) if not requirements: return [] reviews = [item for item in _trusted_artifacts(wf) if item.get("artifact-kind") == "method-judgment-review"] unmet = [] for step_id, gate_id, reviewer in requirements: matched = False for review in reversed(reviews): body = _artifact_content(review, "method-judgment-review") or {} if (body.get("method-role-id") == producer and body.get("method-id") == execution.get("method-id") and body.get("step-id") == step_id and body.get("gate-id") == gate_id and body.get("reviewed-artifact-id") == artifact.get("artifact-id") and body.get("reviewed-artifact-sha256") == artifact.get("artifact-sha256") and str(body.get("reviewer-role-id") or "").upper() == reviewer and str(review.get("producer-role-id") or "").upper() == reviewer and body.get("verdict") == "Passed"): matched = True break if not matched: unmet.append(f"{step_id}/{gate_id}: independent reviewer {reviewer}") return unmet except Exception as exc: return [f"method judgment evaluation failed: {exc}"] def _record_internal_artifact(wf, kind, path, actor="OPS-ORCH"): """Narrow non-report writer for the company-context committer only.""" if kind != "company-context" or actor != "OPS-ORCH": return False, "internal artifact writer는 OPS-ORCH company-context만 허용" try: import artifact_contract as AC ap = AC.absolute_path(path) if not ap or not os.path.isfile(ap): return False, f"artifact 파일 없음: {path}" event = { "artifact-event-id": f"afe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "artifact-submitted", "effective-at": _now(), "submitted-by": actor, "workflow-id": wf, "artifact-id": f"company-context-{AC.sha256_file(ap)[:12]}", "report-id": f"company-context-{AC.sha256_file(ap)[:12]}", "artifact-kind": kind, "design-type": kind, "artifact-version": 1, "producer-role-id": actor, "path": ap, "artifact-sha256": AC.sha256_file(ap), "report-sha256": AC.sha256_file(ap), } with _workflow_lock(wf): if not _append_artifact_event(event): return False, "artifact event append 실패" led = read_ledger(wf) or _default_ledger(wf) led["artifacts"] = _trusted_artifacts(wf) _write_ledger(wf, led) return True, event except Exception as exc: return False, str(exc) def _artifact_by_snapshot(wf, report_path): try: import artifact_contract as AC ap, report = AC.load_report(report_path) ident = AC.identity(report) sha = AC.sha256_file(ap) except Exception as exc: return None, str(exc) for artifact in _trusted_artifacts(wf): if (artifact.get("artifact-id") == str(ident.get("artifact-id")) and artifact.get("artifact-sha256") == sha): return artifact, None return None, "submit-report로 등록된 정확한 artifact revision(id+sha256)이 아니다" def review_artifact(wf, report_path, decision, reviewer, supersedes=None): """Append an authorized decision for one exact artifact revision.""" normalized = str(decision or "").strip().lower() if normalized not in ("accepted", "changes-requested", "blocked"): return False, "decision은 accepted|changes-requested|blocked 중 하나여야 한다" reviewer_id = str(reviewer or "").strip() if reviewer_id not in _role_registry(): return False, f"등록되지 않은 reviewer: {reviewer_id}" artifact, error = _artifact_by_snapshot(wf, report_path) if not artifact: return False, error producer = artifact.get("producer-role-id") if reviewer_id == producer: return False, f"self-review 금지: reviewer={reviewer_id}, producer={producer}" if normalized == "accepted": judgment_unmet = _method_judgment_unmet(wf, artifact) if judgment_unmet: return False, "independent method judgment 미충족: " + "; ".join(judgment_unmet) try: import artifact_contract as AC capability = AC.reviewer_capability(artifact.get("artifact-kind")) definition = ((AC.load_contract().get("artifact-kinds") or {}) .get(artifact.get("artifact-kind")) or {}) except Exception: capability = "artifact-reviewer" definition = {} if not _role_has_capability(reviewer_id, capability): return False, f"reviewer '{reviewer_id}'에 필요한 capability '{capability}'가 없다" required_reviewers = {str(r).upper() for r in (definition.get("required-reviewer-roles") or [])} if required_reviewers and reviewer_id.upper() not in required_reviewers: return False, (f"artifact-kind={artifact.get('artifact-kind')}는 reviewer가 " f"{sorted(required_reviewers)} 중 하나여야 한다(got {reviewer_id})") if supersedes: prior = [a for a in _trusted_artifacts(wf) if a.get("artifact-id") == supersedes] if not prior: return False, f"supersedes 대상 artifact 없음: {supersedes}" try: import acceptance_log as AL event = AL.build_event( artifact.get("artifact-id"), normalized, workflow=wf, role=reviewer_id, supersedes=supersedes, report_sha256=artifact.get("artifact-sha256"), artifact_kind=artifact.get("artifact-kind"), producer_role_id=producer, reviewer={"actor-id": reviewer_id, "role-id": reviewer_id}, authorization={ "expected-reviewer-capability": capability, "producer-role-id": producer, "self-review": False, }, ) errors = AL.validate(event) if errors: return False, "; ".join(errors) with _workflow_lock(wf): if not AL.append_event(event): return False, "acceptance event append 실패" return True, event except Exception as exc: return False, f"review-artifact 실패: {exc}" def _report_payload(report_path): import artifact_contract as AC _ap, report = AC.load_report(report_path) return AC.payload(report), AC.artifact_kind(report) def record_quality_gate(wf, review_path, actor): actor_id = str(actor or "").strip() if not (_role_has_capability(actor_id, "quality-auditor") or _role_has_capability(actor_id, "data-quality-auditor")): return False, f"actor '{actor_id}'에 quality-auditor/data-quality-auditor capability가 없다" ledger = read_ledger(wf) if not ledger or ledger.get("stage") != "verification": return False, "quality gate는 현재 running stage가 verification일 때만 기록할 수 있다" ok, prepared = _prepare_submit_event(wf, review_path, actor_id) if not ok: return False, prepared try: body, kind = _report_payload(review_path) if kind != "quality-gate-review": return False, f"record-quality-gate는 artifact-kind=quality-gate-review만 허용(got {kind})" gate = body.get("quality-gate") status = gate.get("status") if isinstance(gate, dict) else gate if status not in ("Passed", "Failed"): return False, "quality-gate.status는 Passed|Failed여야 한다" blocker_open = body.get("blocker-open") if not isinstance(blocker_open, bool): return False, "blocker-open은 boolean이어야 한다" target_id = body.get("reviewed-artifact-id") target_sha = body.get("reviewed-artifact-sha256") trusted = _trusted_artifacts(wf) target = next((a for a in trusted if a.get("artifact-id") == target_id and a.get("artifact-sha256") == target_sha), None) if not target: return False, "quality review 대상 artifact id+sha256가 등록 revision과 불일치" if target.get("producer-role-id") == actor_id: return False, "quality gate self-review 금지" latest_completion = _latest_artifact_of_kind(trusted, "completion-record") if not latest_completion or target.get("artifact-kind") != "completion-record": return False, "quality review 대상은 completion-record여야 한다" if (target_id, target_sha) != ( latest_completion.get("artifact-id"), latest_completion.get("artifact-sha256")): return False, "quality review 대상은 현재 최신 completion-record exact revision이어야 한다" checks = body.get("checks") or [] findings = body.get("findings") or [] check_ids = [check.get("check-id") for check in checks if isinstance(check, dict)] if len(check_ids) != len(set(check_ids)): return False, "quality checks[].check-id는 중복될 수 없다" derived_blocker = any( finding.get("blocking") is True and finding.get("resolved") is not True for finding in findings if isinstance(finding, dict) ) derived_status = "Passed" if checks and all( check.get("status") == "Passed" for check in checks if isinstance(check, dict) ) and not derived_blocker else "Failed" if status != derived_status: return False, f"quality-gate.status는 checks/findings에서 파생해야 한다(derived={derived_status})" if blocker_open != derived_blocker: return False, f"blocker-open은 unresolved blocking findings에서 파생해야 한다(derived={derived_blocker})" import artifact_contract as AC epoch = _stage_epoch(wf, "verification") or {} strict_receipts = int(ledger.get("evidence-contract-version") or 1) >= 2 completion_source_sha = None if (strict_receipts and ledger.get("tier") in ("standard", "heavy") and any(check.get("status") == "Passed" for check in checks)): try: completion_body, completion_kind = _report_payload(latest_completion.get("path")) except Exception as exc: return False, ("standard/heavy Passed quality gate는 최신 completion-record의 " f"source revision을 읽을 수 있어야 한다: {exc}") source_revision = completion_body.get("source-revision") or {} completion_source_sha = str(source_revision.get("sha256") or "") if (completion_kind != "completion-record" or not re.fullmatch(r"[0-9a-f]{64}", completion_source_sha)): return False, ("standard/heavy Passed quality gate는 최신 completion-record " "payload.source-revision.sha256의 64-hex 결속이 필요하다") passed_receipt_owner = {} for check in checks: receipt_errors, receipts = AC.validate_receipt_ids( review_path, wf, check.get("evidence-receipt-ids") or [], require_success=check.get("status") == "Passed", since=epoch.get("effective-at"), require_context=True, ) if receipt_errors: return False, f"quality check {check.get('check-id')}: " + "; ".join(receipt_errors) if strict_receipts: expected_assertion = "passed" if check.get("status") == "Passed" else "failed" for receipt in receipts: if receipt.get("receipt_type") != "verification-run": return False, (f"quality check {check.get('check-id')}: receipt " f"{AC.receipt_id(receipt)}는 verify_run.py가 발급한 " "verification-run이 아님") if receipt.get("verification_category") != check.get("category"): return False, (f"quality check {check.get('check-id')}: receipt category " f"{receipt.get('verification_category')!r} != " f"check category {check.get('category')!r}") if receipt.get("assertion_status") != expected_assertion: return False, (f"quality check {check.get('check-id')}: assertion_status " f"{receipt.get('assertion_status')!r} != {expected_assertion!r}") if check.get("status") != "Passed": # A failure receipt records what broke, not proof that a # completion revision satisfied a particular criterion. # Keep that legacy diagnostic path permissive. continue check_id = str(check.get("check-id") or "") rid = str(AC.receipt_id(receipt) or "") prior_owner = passed_receipt_owner.get(rid) if prior_owner is not None and prior_owner != check_id: return False, (f"quality receipt {rid} 재사용 금지: Passed checks " f"{prior_owner!r}, {check_id!r}") passed_receipt_owner[rid] = check_id if str(receipt.get("verification_subject") or "") != check_id: return False, (f"quality check {check_id}: receipt {rid}의 " f"verification_subject {receipt.get('verification_subject')!r}가 " "check-id와 불일치") if (ledger.get("tier") in ("standard", "heavy") and str(receipt.get("source_revision_sha256") or "") != completion_source_sha): return False, (f"quality check {check_id}: standard/heavy Passed receipt " "source_revision_sha256가 최신 completion-record " f"revision과 불일치(got=" f"{receipt.get('source_revision_sha256')!r}, " f"expected={completion_source_sha!r})") event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "quality-gate-recorded", "workflow-id": wf, "status": status, "blocker-open": blocker_open, "review-artifact-id": prepared["event"].get("artifact-id"), "review-artifact-sha256": prepared["event"].get("artifact-sha256"), "reviewed-artifact-id": target_id, "reviewed-artifact-sha256": target_sha, "check-count": len(checks), "check-categories": sorted({check.get("category") for check in checks}), "evidence-receipt-ids": sorted({rid for check in checks for rid in check.get("evidence-receipt-ids", [])}), "actor": actor_id, "effective-at": _now(), } artifact_event = None if prepared["existing"] else prepared["event"] committed, error = _atomic_event_transaction( wf, artifact_event=artifact_event, workflow_event=event) if not committed: return False, error return True, event except Exception as exc: return False, f"record-quality-gate 실패: {exc}" def record_release_decision(wf, report_path, actor): actor_id = str(actor or "").strip() if not _role_has_capability(actor_id, "release-decider"): return False, f"actor '{actor_id}'에 release-decider capability가 없다" ledger = read_ledger(wf) if not ledger or ledger.get("stage") != "acceptance": return False, "release decision은 현재 running stage가 acceptance일 때만 기록할 수 있다" ok, prepared = _prepare_submit_event(wf, report_path, actor_id) if not ok: return False, prepared try: body, kind = _report_payload(report_path) if kind != "release-decision": return False, f"record-release-decision은 artifact-kind=release-decision만 허용(got {kind})" decision = body.get("release-decision") status = decision.get("status") if isinstance(decision, dict) else decision if status not in ("Approved", "Held", "Rejected"): return False, "release-decision.status는 Approved|Held|Rejected여야 한다" unresolved = body.get("unresolved-critical-risks") if not isinstance(unresolved, bool): return False, "unresolved-critical-risks는 boolean이어야 한다" completion_binding = ( body.get("reviewed-completion-artifact-id"), body.get("reviewed-completion-artifact-sha256"), ) current_completion = ( ledger.get("current-completion-artifact-id"), ledger.get("current-completion-artifact-sha256"), ) if not all(current_completion) or completion_binding != current_completion: return False, "release decision의 completion binding이 현재 latest completion과 불일치" if (body.get("reviewed-quality-event-id") != ledger.get("current-quality-event-id") or body.get("reviewed-quality-artifact-id") != ledger.get("current-quality-artifact-id") or body.get("reviewed-quality-artifact-sha256") != ledger.get("current-quality-artifact-sha256")): return False, "release decision의 quality binding이 현재 quality review와 불일치" if status == "Approved" and ( ledger.get("quality_gate_status") != "Passed" or ledger.get("blocker-open") or unresolved): return False, "Approved는 현재 quality Passed, blocker 없음, unresolved critical risk 없음에서만 파생 가능" event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "release-decision-recorded", "workflow-id": wf, "status": status, "unresolved-critical-risks": unresolved, "decision-artifact-id": prepared["event"].get("artifact-id"), "decision-artifact-sha256": prepared["event"].get("artifact-sha256"), "reviewed-completion-artifact-id": completion_binding[0], "reviewed-completion-artifact-sha256": completion_binding[1], "reviewed-quality-event-id": body.get("reviewed-quality-event-id"), "reviewed-quality-artifact-id": body.get("reviewed-quality-artifact-id"), "reviewed-quality-artifact-sha256": body.get("reviewed-quality-artifact-sha256"), "quality-event-set": list(ledger.get("current-quality-event-ids") or []), "actor": actor_id, "effective-at": _now(), } artifact_event = None if prepared["existing"] else prepared["event"] committed, error = _atomic_event_transaction( wf, artifact_event=artifact_event, workflow_event=event) if not committed: return False, error return True, event except Exception as exc: return False, f"record-release-decision 실패: {exc}" def block_workflow(wf, report_path, actor="OPS-ORCH"): if not _role_has_capability(actor, "transition-executor"): return False, "block command는 transition-executor만 실행 가능" ok, prepared = _prepare_submit_event(wf, report_path, actor) if not ok: return False, prepared body, kind = _report_payload(report_path) if kind != "blocked-report": return False, "block command는 artifact-kind=blocked-report가 필요" led = read_ledger(wf) if not led or led.get("stage") == "blocked": return False, "workflow가 없거나 이미 blocked 상태다" event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "workflow-blocked", "workflow-id": wf, "blocked-report-id": prepared["event"].get("artifact-id"), "blocked-report-sha256": prepared["event"].get("artifact-sha256"), "blocked-from": led.get("stage"), "resume-condition": body.get("resume-condition"), "actor": actor, "effective-at": _now(), } artifact_event = None if prepared["existing"] else prepared["event"] committed, error = _atomic_event_transaction( wf, artifact_event=artifact_event, workflow_event=event) if not committed: return False, error return True, event def resume_workflow(wf, evidence_path, actor="OPS-ORCH"): if not _role_has_capability(actor, "transition-executor"): return False, "resume command는 transition-executor만 실행 가능" led = read_ledger(wf) if not led or led.get("stage") != "blocked" or not led.get("blocked-from"): return False, "blocked workflow가 아니거나 blocked-from이 없다" ok, prepared = _prepare_submit_event(wf, evidence_path, actor) if not ok: return False, prepared body, kind = _report_payload(evidence_path) if kind != "resume-evidence" or body.get("resume-condition-satisfied") is not True: return False, "resume-evidence와 resume-condition-satisfied:true가 필요" event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "workflow-resumed", "workflow-id": wf, "evidence-artifact-id": prepared["event"].get("artifact-id"), "evidence-artifact-sha256": prepared["event"].get("artifact-sha256"), "to": led.get("blocked-from"), "actor": actor, "effective-at": _now(), } artifact_event = None if prepared["existing"] else prepared["event"] committed, error = _atomic_event_transaction( wf, artifact_event=artifact_event, workflow_event=event) if not committed: return False, error return True, event def read_state_events(wf): canonical = [event for event in read_workflow_events(wf) if event.get("event-type") == "state-transition"] legacy = _read_jsonl(_events_path(wf, create=False)) return legacy + canonical # ---------------------------------------------------------------- fact derivation def _report_id_of(art): rid = art.get("report-id") if rid: return rid path = art.get("path") if path: base = os.path.basename(str(path)) if base.endswith(".report.yaml"): return base[: -len(".report.yaml")] return base return None def _al_accepted_ids(wf, arts=None): """Exact, current, sha-bound accepted artifact ids.""" try: import acceptance_log as AL # noqa: E402 ids = set() if arts is not None: for artifact in arts or []: if not isinstance(artifact, dict): continue rid = _report_id_of(artifact) sha = artifact.get("artifact-sha256") or artifact.get("report-sha256") if rid and sha and AL.is_effectively_accepted(wf, rid, sha): ids.add(rid) return ids for ev in AL.read_events(): if ev.get("decision") != "accepted" or ev.get("workflow-id") != wf: continue rid = ev.get("accepted-report-id") or ev.get("report-id") sha = ev.get("artifact-sha256") or ev.get("report-sha256") if rid and AL.is_effectively_accepted(wf, rid, sha): ids.add(rid) return ids except Exception: return set() def _accepted_design_types(wf, arts): """acceptance_log 에서 accepted 된 산출물의 design-type 집합. finding P0-4: 예전엔 원장 아티팩트의 자기신고 `review-state: Accepted` 만으로도 accepted 로 쳤다 — 에이전트가 아티팩트를 등록하며 스스로 '승인됨'이라 적으면 상태 게이트를 통과할 수 있었다. 이제 승인은 **오직 acceptance_log** 를 통한다(acceptance_log append 는 실존·validate 통과한 report 만 인정 — P0-4c). 원장 아티팩트는 (design-type, report-id) 만 제공하고, 그 report-id 가 acceptance_log 의 accepted 집합에 있을 때만 accepted 로 파생한다.""" # One active revision per artifact-kind: submitting a newer revision makes the # older kind stale for gates until the newer exact id+sha is accepted. latest_by_kind = {} for artifact in arts or []: if isinstance(artifact, dict) and artifact.get("artifact-kind"): latest_by_kind[artifact.get("artifact-kind")] = artifact types = set() try: import acceptance_log as AL # noqa: E402 for kind, artifact in latest_by_kind.items(): rid = _report_id_of(artifact) sha = artifact.get("artifact-sha256") or artifact.get("report-sha256") if rid and sha and AL.is_effectively_accepted(wf, rid, sha): types.add(kind) except Exception: return set() return types def _latest_accepted_artifact(wf, arts, kind): artifact = _latest_artifact_of_kind(arts, kind) if not artifact: return None rid = _report_id_of(artifact) return artifact if rid and rid in _al_accepted_ids(wf, arts) else None def _experience_foundation_required(parent_wf): """Typed workload predicate for the front-of-funnel experience gate.""" profile = _workload_profile(_trusted_artifacts(parent_wf)) surfaces = profile.get("surfaces") if isinstance(profile.get("surfaces"), dict) else {} if not surfaces.get("ui"): return False return ( profile.get("surface-archetype") in ("public-website", "interactive-learning") or profile.get("experience-change") in ("new-product", "major-redesign") ) def _same_snapshot_ref(ref, sha, artifact): if not (ref and sha and artifact and sha == artifact.get("artifact-sha256")): return False try: import artifact_contract as AC return os.path.abspath(AC.absolute_path(ref)) == os.path.abspath( AC.absolute_path(artifact.get("path"))) except Exception: return False def _accepted_experience_feasibility(child_wf, parent_wf, product_decision, strategy, kind): artifact = _latest_accepted_artifact(child_wf, _trusted_artifacts(child_wf), kind) body = _artifact_content(artifact, kind) if artifact else None if not isinstance(body, dict): return None if (body.get("parent-workflow-id") != parent_wf or body.get("product-decision-id") != product_decision or body.get("verdict") != "feasible"): return None if not _same_snapshot_ref(body.get("strategy-ref"), body.get("strategy-sha256"), strategy): return None return artifact def _experience_artifact_bundle(child_wf, parent_wf): """Return six accepted, exact, cross-bound foundation artifacts or None.""" led = _load_ledger_safe(child_wf) if (led.get("plan") != "experience-foundation" or led.get("parent-workflow-id") != parent_wf or led.get("stage") not in ("wireframes", "foundation-approved")): return None product_decision = led.get("product-decision-id") if not _product_decision_current(parent_wf, product_decision): return None arts = _trusted_artifacts(child_wf) bundle = { "benchmark": _latest_accepted_artifact(child_wf, arts, "competitive-experience-benchmark"), "strategy": _latest_accepted_artifact(child_wf, arts, "experience-strategy"), } if any(value is None for value in bundle.values()): return None bundle["technical"] = _accepted_experience_feasibility( child_wf, parent_wf, product_decision, bundle["strategy"], "experience-technical-feasibility") bundle["operational"] = _accepted_experience_feasibility( child_wf, parent_wf, product_decision, bundle["strategy"], "experience-operational-feasibility") bundle["blueprint"] = _latest_accepted_artifact(child_wf, arts, "experience-blueprint") bundle["wireframe"] = _latest_accepted_artifact(child_wf, arts, "wireframe-set") if any(value is None for value in bundle.values()): return None bodies = {key: _artifact_content(value, value.get("artifact-kind")) for key, value in bundle.items()} if any(not isinstance(value, dict) for value in bodies.values()): return None for body in bodies.values(): if (body.get("parent-workflow-id") != parent_wf or body.get("product-decision-id") != product_decision): return None if bodies["strategy"].get("decision") != "proceed": return None if not _same_snapshot_ref(bodies["strategy"].get("benchmark-ref"), bodies["strategy"].get("benchmark-sha256"), bundle["benchmark"]): return None if not (_same_snapshot_ref(bodies["blueprint"].get("benchmark-ref"), bodies["blueprint"].get("benchmark-sha256"), bundle["benchmark"]) and _same_snapshot_ref(bodies["blueprint"].get("strategy-ref"), bodies["blueprint"].get("strategy-sha256"), bundle["strategy"])): return None if not _same_snapshot_ref(bodies["wireframe"].get("blueprint-ref"), bodies["wireframe"].get("blueprint-sha256"), bundle["blueprint"]): return None return bundle def _has_experience_foundation(wf, led): """Validate the parent link and every accepted foundation snapshot fail-closed.""" approval = led.get("experience-foundation-approval") if approval: parent_wf, parent_mode = wf, True elif led.get("plan") == "experience-foundation" and led.get("parent-workflow-id"): parent_wf, parent_mode = led.get("parent-workflow-id"), False approval = (_load_ledger_safe(parent_wf).get("experience-foundation-approval") or {}) if approval.get("child-workflow-id") != wf: return False else: return False child = approval.get("child-workflow-id") if not child: return False child_led = _load_ledger_safe(child) if parent_mode and child_led.get("stage") != "foundation-approved": return False if not parent_mode and child_led.get("stage") not in ("wireframes", "foundation-approved"): return False bundle = _experience_artifact_bundle(child, parent_wf) if not bundle or approval.get("product-decision-id") != child_led.get("product-decision-id"): return False for key, artifact in bundle.items(): if (approval.get(f"{key}-id") != artifact.get("artifact-id") or approval.get(f"{key}-sha256") != artifact.get("artifact-sha256") or not _same_snapshot_ref(approval.get(f"{key}-ref"), approval.get(f"{key}-sha256"), artifact)): return False return True def register_experience_foundation(parent, child): """Atomically bind an accepted foundation bundle to its parent workflow.""" parent_path = _ledger_path(parent, create=False) child_path = _ledger_path(child, create=False) if not parent_path or not os.path.exists(parent_path): raise ValueError(f"parent workflow '{parent}' 원장 없음") if not child_path or not os.path.exists(child_path): raise ValueError(f"child workflow '{child}' 원장 없음") bundle = _experience_artifact_bundle(child, parent) if not bundle: raise ValueError("accepted benchmark/strategy/technical/operational/blueprint/wireframe exact bundle 또는 cross-reference 불충족") child_led = _load_ledger_safe(child) existing = (_load_ledger_safe(parent).get("experience-foundation-approval") or {}) if existing.get("child-workflow-id") not in (None, child): raise ValueError(f"기존 active experience-foundation child={existing.get('child-workflow-id')} 와 충돌") event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "experience-foundation-registered", "workflow-id": parent, "child-workflow-id": child, "product-decision-id": child_led.get("product-decision-id"), "actor": "OPS-ORCH", "effective-at": _now(), } for key, artifact in bundle.items(): event[f"{key}-id"] = artifact.get("artifact-id") event[f"{key}-ref"] = artifact.get("path") event[f"{key}-sha256"] = artifact.get("artifact-sha256") committed, error = _atomic_event_transaction(parent, workflow_event=event) if not committed: raise ValueError(error or "부모 experience-foundation event 기록 실패") return True def _ui_design_release_binding_ok(arts): ui = _latest_artifact_of_kind(arts, "ui-design") body = _artifact_content(ui, "ui-design") if ui else None if not isinstance(body, dict): return False binding = next((item for item in body.get("design-system-bindings", []) if isinstance(item, dict) and item.get("release-id")), None) if not binding: return False try: import artifact_contract as AC path = AC.absolute_path(binding.get("release-ref")) if (not path or not os.path.isfile(path) or AC.sha256_file(path) != binding.get("release-sha256")): return False release = _load_yaml(path).get("design-system-release", {}) if (release.get("release-id") != binding.get("release-id") or release.get("state") not in ("candidate", "stable")): return False if not set(binding.get("component-ids") or []).issubset(set(release.get("components") or [])): return False delta = binding.get("delta") or {} return isinstance(delta.get("tokens"), list) and isinstance(delta.get("components"), list) except Exception: return False def _direction_brief_foundation_refs_ok(parent_wf, brief_ref): approval = (_load_ledger_safe(parent_wf).get("experience-foundation-approval") or {}) path = brief_ref if os.path.isabs(str(brief_ref or "")) else os.path.join(ROOT, str(brief_ref or "")) brief = _load_yaml(path) names = { "benchmark": "competitive-experience-benchmark", "blueprint": "experience-blueprint", "wireframe": "wireframe-set", } for approval_key, brief_key in names.items(): ref = brief.get(f"{brief_key}-ref") sha = brief.get(f"{brief_key}-sha256") if sha != approval.get(f"{approval_key}-sha256"): return False try: import artifact_contract as AC if os.path.abspath(AC.absolute_path(ref)) != os.path.abspath( AC.absolute_path(approval.get(f"{approval_key}-ref"))): return False except Exception: return False return True def _artifact_present(arts, type_aliases): for a in arts or []: if isinstance(a, dict) and a.get("design-type") in type_aliases: return True return False # ---------------------------------------------------------------- preview_ui gate (항목3) def _evidence_receipts(): """evidence-ledger(ledger.jsonl)의 receipt 리스트. degrade -> []. 예외 없음. PostToolUse evidence_ledger 가 쓴 Claude-Code 공급 receipt(command·exit_code·workflow_id)를 읽는다 — 이것이 위조 불가한 실행 증거의 원천(P0-6).""" try: import _workspace as W # noqa: E402 ed = W.evidence_dir() except Exception: return [] p = os.path.join(ed, "ledger.jsonl") if not os.path.exists(p): return [] out = [] try: with open(p, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: obj = json.loads(line) if isinstance(obj, dict): out.append(obj) except Exception: continue except Exception: return [] return out _PNG_SIG = b"\x89PNG\r\n\x1a\n" def _abs_path(p): """상대경로를 ROOT(CLAUDE_PROJECT_DIR) 기준 절대경로로. $VAR 는 export 된 것만 확장.""" if not p: return p p = os.path.expandvars(p) if os.path.isabs(p): return p for base in (ROOT, os.getcwd()): if base: cand = os.path.join(base, p) if os.path.exists(cand): return cand return os.path.join(ROOT or os.getcwd(), p) def _valid_png(path): """실제 렌더된 PNG 인가 — 유효 시그니처(\\x89PNG..) + 비자명 크기(>1000B). 위조 저항 근거.""" try: if not os.path.isfile(path) or os.path.getsize(path) <= 1000: return False with open(path, "rb") as fh: return fh.read(8) == _PNG_SIG except Exception: return False def _render_png_ok(cmd): """finding F9: exit-code 를 못 읽는 환경(모든 receipt exit_code=None)에서도 렌더 게이트를 **실제 실행 산출물**에 접지한다. preview_ui.py 호출 command 에서 출력 위치(--out 값의 형제 스크린샷들·positional project_dir)를 뽑아, 유효한 PNG 스크린샷이 실재하는지 검증한다. exit_code=0 자기신고가 아니라 실물 스크린샷 파일(시그니처+크기)을 확인하므로 위조 저항이 오히려 exit-code 보다 높다 — preview_ui 는 렌더 실패 시 shot 을 쓰기 전에 die() 하므로 유효 PNG 존재 == 실제 DOM 렌더 성공. P0-6 유지: receipt 자체는 여전히 PostToolUse hook 이 실행맥락(session/tool_use_id/cwd)에 결속하며, 임의 명령의 성공을 위장할 수는 없다.""" try: toks = shlex.split(cmd) except Exception: toks = cmd.split() files, dirs = [], [] i = 0 while i < len(toks): t = toks[i] if t in ("--out", "--out=") and i + 1 < len(toks): files.append(toks[i + 1]) i += 2 continue if t.startswith("--out="): files.append(t.split("=", 1)[1]) elif t.lower().endswith(".png"): files.append(t) elif t.endswith("preview_ui.py") and i + 1 < len(toks) and not toks[i + 1].startswith("-"): dirs.append(toks[i + 1]) # positional project_dir i += 1 # --out X/foo.png 는 foo.png 자체가 아니라 foo.w1280.png / foo.state-*.png 형제로 저장된다. for f in files: ap = _abs_path(f) d = os.path.dirname(ap) stem = os.path.splitext(os.path.basename(ap))[0] if _valid_png(ap): return True try: for name in os.listdir(d): if name.lower().endswith(".png") and name.startswith(stem): if _valid_png(os.path.join(d, name)): return True except Exception: continue for dd in dirs: ad = _abs_path(dd) try: for name in os.listdir(ad): if name.lower().endswith(".png") and _valid_png(os.path.join(ad, name)): return True except Exception: continue return False def _has_preview_receipt(wf, prototype=None): """항목3: 이 workflow 에 **통과한 preview_ui 렌더 게이트 receipt** 가 있는가. design-system 산출물이 실제로 렌더·품질검증(preview_ui: #root 비어있지 않음·WCAG 대비·포커스· 반응형)을 통과했음을 evidence-ledger 의 실제 실행 receipt(command=preview_ui.py, exit_code=0)로 확인한다 — 산문 문서만으로 'design-system Accepted' 를 위장하지 못하게 한다. receipt 가 workflow_id 를 가지면 wf 와 일치할 때만 인정, 없으면(느슨한 결속 환경) command·exit 만으로 인정.""" for r in _evidence_receipts(): if not isinstance(r, dict): continue # Typed ``verify_run`` receipts preserve argv as a list so shell quoting # cannot change the evidenced command. Legacy hook receipts used one # ``command`` string. Accept both representations, but always rebuild a # quoted command from the trusted argv list rather than ignoring the new # receipt format (which previously made every typed preview invisible). argv = r.get("command_argv") # A typed receipt's argv is the canonical command representation. Do # not let an optional legacy ``command`` string override it: accepting # two disagreeing command sources makes the supposedly typed receipt # ambiguous. Legacy receipts without argv still use ``command``. if isinstance(argv, list) and argv and all(isinstance(token, str) for token in argv): cmd = shlex.join(argv) else: cmd = str(r.get("command") or "") # 실제 preview_ui.py **호출**만 인정 — grep/find/cat/echo 가 "preview_ui" 를 단순 # 언급하는 receipt(예: `grep -rn "preview_ui" ...`)는 렌더가 아니므로 제외(강화). if not re.search(r"python[0-9]*\s+\S*preview_ui\.py", cmd): continue # --contrast-only 만 돌린 정적 체크는 렌더 게이트가 아니다(빌드·DOM 검증 없음). if "--contrast-only" in cmd: continue # PNG files can survive a failed run (for example CSS health may fail # after responsive screenshots were written). Whenever an exit code is # available it is authoritative; the PNG fallback is only for legacy # hook environments that genuinely omit exit status. exit_code = r.get("exit_code") if exit_code is not None and exit_code != 0: continue if (r.get("receipt_type") == "verification-run" and r.get("assertion_status") != "passed"): continue rwf = r.get("workflow_id") if wf is not None and rwf != wf: continue if not r.get("session_id") or not r.get("agent_id"): continue if isinstance(prototype, dict): expected_receipt = prototype.get("preview-receipt-ref") if not expected_receipt or str(r.get("tool_use_id") or r.get("receipt_id") or "") != str(expected_receipt): continue prototype_path = _abs_path(prototype.get("prototype-path")) if not prototype_path or not os.path.isfile(prototype_path): continue if _sha256_of(prototype_path) != prototype.get("prototype-sha256"): continue try: tokens = [os.path.abspath(_abs_path(token)) for token in shlex.split(cmd) if token and not token.startswith("-")] except Exception: tokens = [] if os.path.dirname(prototype_path) not in tokens and prototype_path not in tokens: continue # 접지(F9): exit-code 를 읽을 수 있으면(exit_code==0) 그대로 인정하고, 못 읽는 환경 # (이 harness 처럼 모든 receipt exit_code=None)에서는 **실제 렌더된 PNG 산출물**로 접지한다. # 사용자 명시 승인(P0-6 렌더 게이트 검증방식 변경) — self-report 가 아니라 실물 스크린샷을 # 검증하므로 현행보다 위조 저항이 높다. P0-6 의 임의-명령-성공-위장 방지는 유지된다. if _render_png_ok(cmd): return True return False _UI_KIND_TOKENS = {"screen", "frontend", "app", "webapp"} def _is_ui_bearing(led, wf=None): """Return the sole UI-bearing signal: trusted workload-profile.payload.surfaces.ui.""" arts = led.get("artifacts") or [] profile = _workload_profile(arts) surfaces = profile.get("surfaces") if isinstance(profile.get("surfaces"), dict) else {} return bool(surfaces.get("ui")) _FOUNDER_CTX = os.path.join(ROOT, "org-os", "01-company", "founder-context.yaml") _COMPANY_CTX_PATH = os.path.join(ROOT, "org-os", "01-company", "company-context.yaml") def _founder_context_filled(): try: return str(_load_yaml(_FOUNDER_CTX).get("status", "")).strip().lower() == "filled" except Exception: return False def _company_ctx_status(): try: return str(_load_yaml(_COMPANY_CTX_PATH).get("status", "")).strip().lower() except Exception: return "" def _company_ctx_lint_ok(): try: import lint_company_context as L hard, _ = L.lint_file(_COMPANY_CTX_PATH, is_candidate=False) return not hard except Exception: return False def _company_ctx_artifact_recorded(arts): return any(isinstance(a, dict) and a.get("design-type") == "company-context" for a in (arts or [])) def _venture_decision_receipt_ok(wf, expected_decision_ids=None): """HUMAN-001 accepted 이벤트가 venture-decision report 의 현재 해시와 일치 바인딩(§9.4). 이벤트에 report-sha256 이 있고(Task 15), report 파일이 실존하며 해시 일치할 때만 True. candidate commit 때 expected_decision_ids를 주면 그 source-decision-id와도 정확히 결속한다.""" try: import acceptance_log as AL, hashlib expected = {str(value) for value in (expected_decision_ids or []) if str(value).strip()} for ev in reversed(list(AL.read_events())): if ev.get("decision") != "accepted": continue if ev.get("workflow-id") not in (None, wf): continue if str(ev.get("role-id", "")).upper() != "HUMAN-001".upper(): continue if ev.get("artifact-kind") != "venture-decision": continue if str(ev.get("producer-role-id", "")).upper() != "EXEC-CEO": continue sha = ev.get("report-sha256") rid = ev.get("accepted-report-id") or ev.get("report-id") if not (sha and rid): continue if expected and str(rid) not in expected: continue path = AL._resolve_report_path(rid, wf) if not path or not os.path.exists(path): continue actual = hashlib.sha256(open(path, "rb").read()).hexdigest() if actual == sha and AL.is_effectively_accepted(wf, rid, sha): return True return False except Exception: return False def _distinct_opportunity_cluster_count(artifacts): """Trusted opportunity-cluster snapshots의 서로 다른 payload.id 개수.""" cluster_ids = set() for artifact in artifacts or []: if not isinstance(artifact, dict) or artifact.get("design-type") != "opportunity-cluster": continue body = _artifact_content(artifact, "opportunity-cluster") or {} cluster_id = str(body.get("id") or "").strip() if cluster_id: cluster_ids.add(cluster_id) return len(cluster_ids) def _dd_active(led): return led.get("design-direction-active") or {} def _active_artifact(led, arts, design_type, id_key): """active cycle pointer 가 가리키는 report-id 의 아티팩트만 반환(오래된 것 무시, Blocker 6). want 이 있으면 정확히 일치하는 것만(없으면 None, fail closed) — 오래된 아티팩트로 대체되는 것을 막는다. want 이 없으면(포인터 미명시) 최신(마지막) 아티팩트를 사용.""" want = _dd_active(led).get(id_key) match = None for a in (arts or []): if isinstance(a, dict) and a.get("design-type") == design_type: if want is not None: if a.get("report-id") == want: return a else: match = a # 포인터가 이 아티팩트 report-id를 명시 안 하면 최신(마지막) 사용 return None if want is not None else match def _artifact_content(a, design_type): """Load the immutable submitted snapshot payload (legacy embedded form is read-compatible).""" if not isinstance(a, dict): return None embedded = a.get(design_type) if isinstance(embedded, dict): return embedded path = a.get("path") if path: try: import artifact_contract as AC ap = AC.absolute_path(path) except Exception: ap = path if os.path.isabs(path) else os.path.join(ROOT, path) if os.path.exists(ap): try: doc = _load_yaml(ap) if doc.get("report-type") == "workflow-artifact" and isinstance(doc.get("payload"), dict): doc = doc["payload"] # 파일이 {design_type: {...}} 래퍼든 평문 dict 든 모두 지원 if isinstance(doc, dict): return doc.get(design_type) if isinstance(doc.get(design_type), dict) else doc except Exception: return None return None def _dd_input_brief_ref(led): return led.get("direction-input-brief-ref") or (led.get("facts") or {}).get("direction-input-brief-ref") def _current_input_brief_sha(led): ref = _dd_input_brief_ref(led) if not ref: return None p = ref if os.path.isabs(ref) else os.path.join(ROOT, ref) return hashlib.sha256(open(p, "rb").read()).hexdigest() if os.path.exists(p) else None def _direction_experience_inputs_ok(led, direction_set, audit=None): parent = led.get("parent-workflow-id") if not parent or not _experience_foundation_required(parent): return True approval = (_load_ledger_safe(parent).get("experience-foundation-approval") or {}) if not approval: return False checks = (("experience-blueprint", "blueprint"), ("wireframe-set", "wireframe")) for document_key, approval_key in checks: if (direction_set.get(f"{document_key}-sha256") != approval.get(f"{approval_key}-sha256") or not _direction_brief_foundation_refs_ok(parent, _dd_input_brief_ref(led))): return False try: import artifact_contract as AC if os.path.abspath(AC.absolute_path(direction_set.get(f"{document_key}-ref"))) != os.path.abspath( AC.absolute_path(approval.get(f"{approval_key}-ref"))): return False except Exception: return False wireframe_sha = approval.get("wireframe-sha256") if any(item.get("content-contract-sha256") != wireframe_sha for item in (direction_set.get("directions") or []) if isinstance(item, dict)): return False if audit is not None: if audit.get("competitive-experience-benchmark-sha256") != approval.get("benchmark-sha256"): return False try: import artifact_contract as AC if os.path.abspath(AC.absolute_path(audit.get("competitive-experience-benchmark-ref"))) != os.path.abspath( AC.absolute_path(approval.get("benchmark-ref"))): return False except Exception: return False findings = audit.get("benchmark-relative-findings") or [] if not isinstance(findings, list) or len(findings) < 3: return False return True def _directions_diverged(led, arts): a = _active_artifact(led, arts, "direction-set", "direction-set-report-id") ds = _artifact_content(a, "direction-set") if not isinstance(ds, dict): return False if not _direction_experience_inputs_ok(led, ds): return False dirs = ds.get("directions") or [] if len(dirs) < 3: return False runs = [d.get("producer-run-id") for d in dirs] pkgs = [d.get("context-package-id") for d in dirs] if None in runs or len(set(runs)) != len(runs): return False if None in pkgs or len(set(pkgs)) != len(pkgs): return False if not (ds.get("representative-screen") or {}).get("id"): return False cp = ds.get("comparison-preview") or {} if not (cp.get("receipt-ref") and cp.get("gallery-path")): return False for d in dirs: cs = d.get("coded-slice") if not cs: return False ap = cs if os.path.isabs(cs) else os.path.join(ROOT, cs) if not os.path.isfile(ap): return False # 실제 픽셀 = 파일(디렉터리면 open() 크래시 대신 무효 처리, F6) csha = d.get("coded-slice-sha256") if not csha or hashlib.sha256(open(ap, "rb").read()).hexdigest() != csha: return False # 실존만으로 부족 — 내용까지 대조(state-transition-rules.yaml:120 실존/hash); 필드 생략도 차단(우회방지) return True def _divergence_charter_ok(led, arts): """Active charter must be a real, lint-clean submitted snapshot.""" artifact = _active_artifact(led, arts, "divergence-charter", "divergence-charter-report-id") if not (artifact and artifact.get("path")): return False try: import artifact_contract as AC import lint_design_direction as LDD path = AC.absolute_path(artifact["path"]) hard, _warn = LDD.lint_file(path, "divergence-charter") return not hard except Exception: return False def _divergence_audit_ok(wf, led, arts): """Selection is impossible until a comparative, sibling-visible audit passes. This deliberately differs from divergence worker isolation: the audit must bind and compare all three originals, cover every pair, validate full-size preview hashes, and have no primitive collision or blocking finding. The charter and audit themselves must be accepted exact revisions; a bare synthesis string cannot substitute for them. """ charter = _active_artifact(led, arts, "divergence-charter", "divergence-charter-report-id") dset = _active_artifact(led, arts, "direction-set", "direction-set-report-id") audit = _active_artifact(led, arts, "comparative-divergence-audit", "divergence-audit-report-id") if not all(a and a.get("path") for a in (charter, dset, audit)): return False accepted = _al_accepted_ids(wf, arts) if _report_id_of(charter) not in accepted or _report_id_of(audit) not in accepted: return False try: import artifact_contract as AC import lint_design_direction as LDD hard, _warn = LDD.lint_divergence_bundle( AC.absolute_path(audit["path"]), AC.absolute_path(dset["path"]), AC.absolute_path(charter["path"])) if hard: return False audit_doc = _artifact_content(audit, "comparative-divergence-audit") or {} ds_doc = _artifact_content(dset, "direction-set") or {} if not _direction_experience_inputs_ok(led, ds_doc, audit_doc): return False producer_runs = {d.get("producer-run-id") for d in (ds_doc.get("directions") or []) if isinstance(d, dict) and d.get("producer-run-id")} return (str(audit_doc.get("reviewer-role-id") or "").upper() == "DES-VISUAL" and audit_doc.get("reviewer-run-id") not in producer_runs) except Exception: return False def _selected_direction_ok(led, arts): sd = _active_artifact(led, arts, "selected-direction", "selected-direction-report-id") dset = _active_artifact(led, arts, "direction-set", "direction-set-report-id") if not (sd and dset and sd.get("path") and dset.get("path")): return False try: import artifact_contract as AC import lint_design_direction as LDD hard, _ = LDD.lint_selected_direction( AC.absolute_path(sd["path"]), AC.absolute_path(dset["path"])) return not hard except Exception: return False def _selected_direction_accepted_ok(wf, led, arts): """Task 15 item 7(리뷰): `_selected_direction_ok`(bundle lint) 만으로는 부족하다 — design-direction.md §3이 명시하는 계약("selected-direction-accepted 는 bundle lint 통과 **+** 이 accepted 이벤트 둘 다 요구", 즉 selected-direction 자신의 report-id 에 대한 accepted 이벤트)을 정확히 지켜야 한다. 예전엔 `_al_has_accepted(wf)`(이 workflow 에 **아무 report나** accepted 됐는지만 확인)를 썼는데, 이는 이 workflow 에서 우연히·별도로 accepted 된 무관한 report(예: 다른 design-type 산출물)로도 이 게이트를 통과시킬 수 있었다(대체 가능 = 위조 경로). `_al_accepted_ids(wf)`(exact report-id matching — `_product_decision_current`/`_accepted_design_types`와 동일 패턴)로 active selected-direction 아티팩트의 정확한 report-id 가 accepted 됐는지만 인정한다.""" if not _selected_direction_ok(led, arts): return False sd = _active_artifact(led, arts, "selected-direction", "selected-direction-report-id") body = _artifact_content(sd, "selected-direction") or {} if body.get("selection-decision") == "none-of-the-above": return False rid = _report_id_of(sd) if sd else None return bool(rid) and rid in _al_accepted_ids(wf, arts) def _none_of_the_above_recorded(wf, led, arts): sd = _active_artifact(led, arts, "selected-direction", "selected-direction-report-id") if not sd or not _selected_direction_ok(led, arts): return False body = _artifact_content(sd, "selected-direction") or {} rid = _report_id_of(sd) return (body.get("selection-decision") == "none-of-the-above" and bool(rid) and rid in _al_accepted_ids(wf, arts)) def _critique_panel_ok(led, arts): a = _active_artifact(led, arts, "design-review-panel", "review-panel-report-id") panel = _artifact_content(a, "design-review-panel") if not isinstance(panel, dict): return False required = {"product-fit", "usability", "distinctiveness", "visual-craft", "systematizability", "market-memorability", "implementability"} reviews = panel.get("reviews") or [] review_lenses = [r.get("lens") for r in reviews if isinstance(r, dict)] if set(review_lenses) != required or len(review_lenses) != len(required): return False lens_roles = { "product-fit": "DES-PROD", "usability": "UX-RESEARCHER", "distinctiveness": "DES-VISUAL", "visual-craft": "DES-VISUAL", "systematizability": "DES-PLATFORM", "market-memorability": "GTM-PMM", "implementability": "ENG-FE", } if any(str(review.get("reviewer-role-id") or "").upper() != lens_roles.get(review.get("lens")) for review in reviews): return False reviewer_runs = [review.get("reviewer-run-id") for review in reviews] if None in reviewer_runs or len(set(reviewer_runs)) != len(reviewer_runs): return False trusted_panel = bool(a.get("artifact-sha256")) winner = _active_artifact(led, arts, "winner-prototype", "prototype-report-id") if trusted_panel: if not winner: return False if (panel.get("target-prototype-id"), panel.get("target-prototype-sha256")) != ( _report_id_of(winner), winner.get("artifact-sha256")): return False # Fix B(최종리뷰): producer≠reviewer 를 run-id 문자열 자기신고만으로 확인하던 것을 강화한다 # (design-direction-design.md §8 "각 review report hash 검증"). 각 review 가 가리키는 # report-ref 파일이 실존하고, 그 **라이브 sha256** 이 report-sha256 과 일치할 때만 그 review 를 # 진짜로 인정한다 — `_directions_diverged`의 coded-slice hash 대조, `_artifact_content`/ # `_selected_direction_ok`와 동일한 ROOT-relative 경로 해석. 참조·해시 누락/파일 부재/불일치는 # 전부 fail closed(위조·스테일 review 로 패널을 통과시키는 것을 차단). def _normalized_verdict(value): value = str(value or "").strip().lower() return {"pass": "pass", "passed": "pass", "concerns": "revise", "minor-revision": "revise", "revise": "revise", "blocking": "blocking", "concept-flaw": "blocking", "failed": "blocking"}.get(value) for r in reviews: ref = r.get("report-ref") sha = r.get("report-sha256") if not (ref and sha and r.get("reviewer-role-id") and r.get("reviewer-run-id") and r.get("lens") and r.get("verdict")): return False ap = ref if os.path.isabs(ref) else os.path.join(ROOT, ref) if not os.path.exists(ap): return False if _sha256_of(ap) != sha: return False if trusted_panel: try: import artifact_contract as AC except Exception: return False submitted = next((item for item in arts or [] if item.get("artifact-kind") == "design-lens-review" and _report_id_of(item) == r.get("report-id") and item.get("artifact-sha256") == sha and os.path.abspath(AC.absolute_path(item.get("path"))) == os.path.abspath(ap)), None) if not submitted: return False # The panel summary cannot misrepresent the hash-bound source review. try: envelope = _load_yaml(ap) source = (envelope.get("payload") if envelope.get("report-type") == "workflow-artifact" and isinstance(envelope.get("payload"), dict) else envelope) except Exception: return False for key in ("lens", "reviewer-role-id", "reviewer-run-id"): if str(source.get(key) or "") != str(r.get(key) or ""): return False if trusted_panel: ident = envelope.get("identity") if isinstance(envelope.get("identity"), dict) else {} payload_source = source if str(ident.get("producer-role-id") or "").upper() != str(r.get("reviewer-role-id") or "").upper(): return False if (payload_source.get("target-prototype-id"), payload_source.get("target-prototype-sha256")) != ( _report_id_of(winner), winner.get("artifact-sha256")): return False if _normalized_verdict(source.get("verdict")) != _normalized_verdict(r.get("verdict")): return False # A pass synthesis requires every lens to pass. Concerns are revision work, # never non-blocking prose that the synthesis lead may silently override. if _normalized_verdict(r.get("verdict")) != "pass": return False findings = source.get("findings") or [] for finding in findings if isinstance(findings, list) else []: if not isinstance(finding, dict): continue severity = str(finding.get("severity") or "").strip().lower() if severity in ("blocking", "critical") or finding.get("blocking") is True: return False ds = _active_artifact(led, arts, "direction-set", "direction-set-report-id") if not ds: return False # 직물 검증 불가 -> fail closed ds_content = _artifact_content(ds, "direction-set") or {} producer_runs = {d.get("producer-run-id") for d in (ds_content.get("directions") or []) if d.get("producer-run-id")} if not producer_runs: return False # 대조할 producer 없음(전부 None 포함) -> fail closed if any(r.get("reviewer-run-id") in producer_runs for r in reviews): return False syn = panel.get("synthesis") or {} dissent = syn.get("unresolved-dissent") return (syn.get("verdict") == "pass" and str(syn.get("role-id", "")).upper() == "DES-DIRECTOR" and isinstance(dissent, list) and not dissent) def _panel_verdict(led, arts): a = _active_artifact(led, arts, "design-review-panel", "review-panel-report-id") panel = _artifact_content(a, "design-review-panel") return (panel or {}).get("synthesis", {}).get("verdict") if a else None def _approval_brief_matches(approved_doc, current_sha): """staleness helper. current_sha 가 없으면(파일 미해석/부재) fail-closed(False) — '측정 불가'를 통과로 치지 않는다. 있으면 승인문서에 박제된 direction-input-brief-sha256 과 정확히 일치할 때만 stale 아님(True).""" return bool(current_sha) and approved_doc.get("direction-input-brief-sha256") == current_sha def _dd_ws_path(path): """report-ref/selected-direction-ref/winner-prototype-ref 를 **workspace root**(W.work_root()) 기준 상대경로로 해석한다(이 참조들은 org-os SSOT 가 아니라 워크스페이스 산출물이므로 _current_input_brief_sha 가 쓰는 레포 ROOT 기준과 다르다). 절대경로면 그대로. 실존하지 않으면 None(호출부가 fail-closed 처리).""" if not path: return None if os.path.isabs(path): ap = path else: try: import _workspace as W # noqa: E402 ap = os.path.join(W.work_root(), path) except Exception: return None return ap if os.path.exists(ap) else None def _sha256_of(path): try: return hashlib.sha256(open(path, "rb").read()).hexdigest() except Exception: return None def _product_decision_current(parent_wf, pd_id): """product-decision(pd_id) 이 parent_wf 에서 여전히 CURRENT(비-superseded) accepted 인가. 1) `_al_accepted_ids(parent_wf)` 로 **정확히 이 id** 가 accepted 된 적 있는지 확인한다 (init_ledger 의 기존 위조방지 패턴 재사용 — 부모가 '아무거나' accepted 했다는 사실만으로 통과하지 않는다). 2) acceptance_log 이벤트 중 이 pd_id 를 `supersedes-report-id` 로 지목한 이벤트가(그 부모 스코프에서) 하나라도 있으면, 더 새로운 결정이 이를 대체했다는 뜻이므로 False(supersede 를 무시하면 폐기된 옛 product-decision 으로 direction-approval 이 영구히 유효해진다).""" if not pd_id: return False try: parent_arts = (_load_ledger_safe(parent_wf).get("artifacts") or []) if pd_id not in _al_accepted_ids(parent_wf, parent_arts): return False import acceptance_log as AL # noqa: E402 for ev in AL.read_events(): if ev.get("workflow-id") not in (None, parent_wf): continue if ev.get("supersedes-report-id") == pd_id: return False return True except Exception: return False def _direction_approval_receipt_ok(child_wf, report_ref, report_sha): """acceptance_log 에 이 approved-direction report 를 accepted 로 정확히 바인딩한 이벤트가 있는가. workflow-id 는 child_wf 와 **EXACT** 일치해야 한다(None 은 불허 — 느슨한 매칭으로 아무 child 나 편승하는 것을 차단, receipt 자기신고 위조 방지). report-sha256 도 정확히 일치해야 한다(다른 리포트의 accepted 이벤트를 재사용하는 것을 차단). Important fix(리뷰, `_venture_decision_receipt_ok` 와 동일 패턴): report-sha256 **필드값**만 대조하면, 이벤트가 실존하지 않거나 무관한 report-id 를 달고도 그 필드에 approved-direction 의 해시를 그대로 베껴 자기신고할 수 있다(값은 맞는데 가리키는 파일은 다른 상황). 그래서 이벤트가 가리키는 report-id 를 실제 completion-records 경로로 재해석(`AL._resolve_report_path`)해 파일이 실존하고 그 **라이브 sha256**도 report_sha 와 일치할 때만 인정한다 — accepted 로 표시된 그 파일의 실제 내용이 승인된 direction 의 해시로 귀결돼야 진짜 바인딩이다.""" try: import acceptance_log as AL # noqa: E402 for ev in AL.read_events(): if ev.get("decision") != "accepted": continue if ev.get("workflow-id") != child_wf: # None 불허 — 정확 일치만 인정 continue if ev.get("report-sha256") != report_sha: continue rid = ev.get("accepted-report-id") or ev.get("report-id") if not rid: continue path = AL._resolve_report_path(rid, child_wf) if not path or not os.path.exists(path): continue if _sha256_of(path) != report_sha: continue if not AL.is_effectively_accepted(child_wf, rid, report_sha): continue return True return False except Exception: return False def _has_direction_approval(wf, led): """§7 exact 8점 검증. fail-open 없음 — 실패 경로는 전부 False 를 반환한다. 호출 모양(shape) 2종을 모두 지지한다. `_facts(wf, led)` 는 **평가 대상 워크플로 자신의** wf/led 를 그대로 이 함수에 넘기는데, 이 사실이 쓰이는 두 자리의 '자신'이 다르다: (a) 부모(cascade) 자신의 facts 평가 — 예: Phase F `check-direction-approved --workflow `, 상위 cascade 가 /design-system 진입 전 게이트로 확인할 때. 이때 led 에 `design-direction-approval` 링크가 직접 있다. (b) design-direction **자식** 자신의 facts 평가 — 자식의 `design-direction-finalize -> design-direction-approved` 전이 자체가 이 사실을 조건으로 요구하므로(approved-direction -valid/approval-receipt-bound/parent-approval-link-recorded 3종 모두 이 사실 하나에서 파생), `guard/transition --workflow `가 호출하는 `_facts(child, child_led)` 도 이 함수를 반드시 통과시켜야 한다. 이땐 led 에 `parent-workflow-id`만 있고 승인 링크 자체는 **부모** 원장에 있으므로, 부모로 거슬러 올라가 그 링크가 정확히 이 자식(wf)을 가리키는지부터 확인한다(가리키지 않으면 위조/오배선 -> False). 두 경우 모두 아니면(예: 평범한 cascade 원장) 이 게이트와 무관 -> False. Critical fix(리뷰): (b) 모드는 자식의 `finalize -> approved` 전이 자체를 평가하는 도중에 호출되므로, 평가 시점의 child stage 는 아직 `design-direction-finalize` 다 — 여기서 `design-direction-approved` 를 요구하면(3번 체크) 그 전이가 만드는 바로 그 stage 를 전이 성립 조건으로 요구하는 셈이라 전이가 영원히 발동할 수 없다(교착). 그래서 3번 체크는 `parent_mode` 로 분기한다: (a) parent-shape 는 자식이 이미 완전히 종료(approved)됐음을 요구하고, (b) child-shape 는 전이가 떠나는 stage(finalize) 도 허용한다. 다른 체크(1,2,4~8)는 두 모드 모두 동일하게(완화 없이) 수행한다.""" approval = led.get("design-direction-approval") if approval: parent_wf = wf parent_mode = True elif led.get("parent-workflow-id"): parent_wf = led.get("parent-workflow-id") parent_path = _ledger_path(parent_wf, create=False) if not parent_path or not os.path.exists(parent_path): return False parent_led = _load_ledger_safe(parent_wf) approval = parent_led.get("design-direction-approval") or {} if approval.get("child-workflow-id") != wf: return False # 부모 링크가 이 자식을 가리키지 않음 parent_mode = False else: return False report_ref = approval.get("report-ref") report_sha = approval.get("report-sha256") child = approval.get("child-workflow-id") if not (report_ref and report_sha and child): return False # 1) 부모 원장 approval 링크(report-ref+report-sha256+child-workflow-id) 불완전 # 2) child 실존(파일 존재로 직접 확인 — _load_ledger_safe 는 부재도 기본원장으로 감춘다) + 관계 child_path = _ledger_path(child, create=False) if not child_path or not os.path.exists(child_path): return False child_led = _load_ledger_safe(child) if child_led.get("parent-workflow-id") != parent_wf: return False # 3) child stage — mode-dependent(Critical fix, 교착 해소): parent-shape(a)는 자식이 이미 # 종료(approved)됐음을 요구하고, child-shape(b)는 이 fact 자체가 만들어내는 stage(approved)를 # 평가 시점(아직 finalize)에 요구하면 전이가 영원히 발동 못 하므로 finalize 도 허용한다. # 어느 모드든 그 외 stage(예: critique/prototype 등으로의 역행·오염)는 여전히 거부(fail-closed). if parent_mode: if child_led.get("stage") != "design-direction-approved": return False else: if child_led.get("stage") not in ("design-direction-finalize", "design-direction-approved"): return False # 4) approved-direction report 파일 실존 + hash 일치(workspace 상대경로) ap = _dd_ws_path(report_ref) if not ap: return False if _sha256_of(ap) != report_sha: return False raw = _load_yaml(ap) if raw.get("report-type") == "workflow-artifact" and isinstance(raw.get("payload"), dict): raw = raw["payload"] doc = raw.get("approved-direction") if isinstance(raw.get("approved-direction"), dict) else raw if not isinstance(doc, dict): return False # 5) doc 의 parent/child 가 정확히 이 쌍과 일치 if doc.get("parent-workflow-id") != parent_wf or doc.get("child-workflow-id") != child: return False # 6) product-decision-id 일치 + 부모에서 현재(비-superseded) accepted pd_id = doc.get("product-decision-id") if not pd_id or pd_id != child_led.get("product-decision-id"): return False if not _product_decision_current(parent_wf, pd_id): return False # 7) staleness: 자식의 현재(live) input-brief hash == 승인문서에 박제된 hash current_sha = _current_input_brief_sha(child_led) if not _approval_brief_matches(doc, current_sha): return False # 8) 참조 아티팩트(selected-direction/winner-prototype) 실존+hash 일치 + acceptance receipt 정확 바인딩 sd_ap = _dd_ws_path(doc.get("selected-direction-ref")) if not sd_ap or _sha256_of(sd_ap) != doc.get("selected-direction-sha256"): return False wp_ap = _dd_ws_path(doc.get("winner-prototype-ref")) if not wp_ap or _sha256_of(wp_ap) != doc.get("winner-prototype-sha256"): return False if not _direction_approval_receipt_ok(child, report_ref, report_sha): return False return True def register_direction_approval(parent, child, report, report_sha256): """trusted CLI 백엔드 — 전 검증 통과 후에만 부모 원장에 `design-direction-approval` 을 원자적으로 기록한다(guard_tools 가 직접 YAML 편집을 막으므로 이 CLI 가 유일한 등록 경로). 검증 실패 시 ValueError 를 던진다(원장은 건드리지 않는다) — 호출측(CLI)이 non-zero exit 로 변환한다.""" child_path = _ledger_path(child, create=False) if not child_path or not os.path.exists(child_path): raise ValueError(f"child workflow '{child}' 원장 없음") child_led = _load_ledger_safe(child) if child_led.get("parent-workflow-id") != parent: raise ValueError(f"child '{child}' 가 parent '{parent}' 의 자식이 아님") if child_led.get("stage") not in ("design-direction-finalize", "design-direction-approved"): raise ValueError(f"child stage '{child_led.get('stage')}' 는 finalize/approved 가 아님") ap = _dd_ws_path(report) if not ap: raise ValueError(f"report 경로 실존하지 않음: {report}") if _sha256_of(ap) != report_sha256: raise ValueError("report sha256 불일치") artifact, artifact_error = _artifact_by_snapshot(child, ap) if not artifact or artifact.get("artifact-kind") != "approved-direction": raise ValueError(artifact_error or "submit-artifact된 approved-direction exact revision이 아니다") parent_path = _ledger_path(parent, create=False) if not parent_path or not os.path.exists(parent_path): raise ValueError(f"parent workflow '{parent}' 원장 없음") parent_led = _load_ledger_safe(parent) existing = parent_led.get("design-direction-approval") or {} existing_child = existing.get("child-workflow-id") if existing_child and existing_child != child: raise ValueError(f"기존 active approval(child={existing_child}) 과 충돌") event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "direction-approval-registered", "workflow-id": parent, "child-workflow-id": child, "report-ref": report, "report-sha256": report_sha256, "actor": "OPS-ORCH", "effective-at": _now(), } committed, error = _atomic_event_transaction(parent, workflow_event=event) if not committed: raise ValueError(error or "부모 direction approval event 기록 실패") return True def _present(led, arts, type_aliases, flag_key): # Gate presence is derived only from trusted artifact events. Explicit # ledger flags were the old bypass (`facts.*-present: true`). return _artifact_present(arts, set(type_aliases)) def _workload_profile(arts): profile = {} for artifact in arts or []: if not isinstance(artifact, dict): continue if artifact.get("artifact-kind") in ("decision-brief", "workload-profile"): value = artifact.get("workload-profile") if isinstance(value, dict): profile = value return profile def _competitive_market_grounding_required(wf): """Public/new/major work needs market grounding during /ground, not later design research.""" profile = _workload_profile(_trusted_artifacts(wf)) return ( profile.get("surface-archetype") == "public-website" or profile.get("experience-change") in ("new-product", "major-redesign") ) def _role_agent_id(value): return str(value or "").strip().upper().replace("_", "-") def _grounding_lens_coverage(wf, led, arts): """Verify discovery contributions from immutable reports and context packages. Grounding-package summaries are projections only. Every covered lens is re-derived from the exact report revision, its registered producer role and the immutable context package that assigned the lens. """ package = _latest_artifact_of_kind(arts, "grounding-package") body = _artifact_content(package, "grounding-package") if package else None if not isinstance(body, dict): return False, "grounding-package 없음" contributions = body.get("source-contributions") or [] if not isinstance(contributions, list): return False, "grounding-package.source-contributions array 필요" try: import acceptance_log as AL import artifact_contract as AC from orgos.planning.lens_policy import ( divergent_policy, family_for_role, required_lenses, role_can_carry_lens, ) except Exception as exc: return False, f"grounding lens policy 로드 실패(fail-closed): {exc}" trusted_by_revision = { (str(item.get("artifact-id") or ""), str(item.get("artifact-sha256") or "")): item for item in arts if isinstance(item, dict) } errors = [] seen_reports, seen_packages, seen_runs = set(), set(), set() covered = set() contrarian_reports = [] resolved = {} package_epoch = package.get("stage-epoch-id") package_producer_family, _ = family_for_role(package.get("producer-role-id")) brief = _latest_artifact_of_kind(arts, "decision-brief") brief_body = _artifact_content(brief, "decision-brief") or {} candidate_families = {str(value).upper() for value in brief_body.get("candidate-families", []) or []} if not candidate_families: errors.append("현재 Decision Brief의 non-empty candidate-families 없음") for index, ref in enumerate(contributions): prefix = f"source-contributions[{index}]" if not isinstance(ref, dict): errors.append(f"{prefix}: object 필요") continue report_id = str(ref.get("report-id") or "") report_sha = str(ref.get("report-sha256") or "") report_key = (report_id, report_sha) context_key = (str(ref.get("context-package-ref") or ""), str(ref.get("context-package-sha256") or "")) run_id = str(ref.get("producer-run-id") or "") lens = str(ref.get("assigned-lens") or "").upper() if report_key in seen_reports: errors.append(f"{prefix}: report id/SHA 중복") if context_key in seen_packages: errors.append(f"{prefix}: context package ref/SHA 중복") if run_id in seen_runs: errors.append(f"{prefix}: producer-run-id 중복") seen_reports.add(report_key) seen_packages.add(context_key) seen_runs.add(run_id) artifact = trusted_by_revision.get(report_key) if not artifact: errors.append(f"{prefix}: 현재 workflow trusted report id/SHA 불일치") continue if artifact.get("artifact-kind") not in ("grounding-contribution", "competitive-market-grounding"): errors.append(f"{prefix}: grounding contribution artifact-kind 아님") continue if artifact.get("workflow-id") != wf or artifact.get("stage") != "discovery": errors.append(f"{prefix}: 동일 workflow/discovery stage가 아님") if package_epoch and artifact.get("stage-epoch-id") != package_epoch: errors.append(f"{prefix}: 현재 discovery 실행 epoch와 불일치(stale)") if not _same_snapshot_ref(ref.get("report-ref"), report_sha, artifact): errors.append(f"{prefix}: report-ref/live SHA exact binding 불일치") producer = str(artifact.get("producer-role-id") or "").upper() if producer not in _role_registry(): errors.append(f"{prefix}: 미등록 producer role {producer!r}") if str(ref.get("producer-role-id") or "").upper() != producer: errors.append(f"{prefix}: producer-role-id가 report identity와 불일치") if not role_can_carry_lens(producer, lens): errors.append(f"{prefix}: role {producer}는 registry상 {lens}를 carry할 수 없음") producer_family, _producer_family_doc = family_for_role(producer) if producer_family not in candidate_families: errors.append(f"{prefix}: producer family {producer_family}가 Decision Brief candidate-families 밖") contribution = _artifact_content(artifact, artifact.get("artifact-kind")) or {} if (str(contribution.get("assigned-lens") or "").upper() != lens or str(contribution.get("producer-run-id") or "") != run_id): errors.append(f"{prefix}: report payload lens/run이 source ref와 불일치") if (str(contribution.get("context-package-ref") or "") != context_key[0] or str(contribution.get("context-package-sha256") or "") != context_key[1]): errors.append(f"{prefix}: report payload context package binding 불일치") context_path = AC.absolute_path(context_key[0]) if not context_path or not os.path.isfile(context_path): errors.append(f"{prefix}: context package 파일 없음") elif AC.sha256_file(context_path) != context_key[1]: errors.append(f"{prefix}: context package live SHA 불일치") else: context = _load_yaml(context_path) if context.get("workflow-id") != wf: errors.append(f"{prefix}: context package workflow-id 불일치") if context.get("mode") != "divergent": errors.append(f"{prefix}: context package mode=divergent 필요") if str(context.get("tier") or "").lower() != str(led.get("tier") or "").lower(): errors.append(f"{prefix}: context package tier 불일치") if str(context.get("assigned-lens") or "").upper() != lens: errors.append(f"{prefix}: context package assigned-lens 불일치") if _role_agent_id(context.get("target-role-agent")) != producer: errors.append(f"{prefix}: context package target-role-agent와 producer 불일치") decision = AL.effective_decision(wf, report_id, report_sha) if decision in ("Superseded", "ChangesRequested", "Blocked"): errors.append(f"{prefix}: stale/rejected contribution({decision})") covered.add(lens) resolved[report_id] = artifact if lens == "LENS-CONTRARIAN": contrarian_reports.append(report_id) contrarian_family, _family = family_for_role(producer) if contrarian_family == package_producer_family: errors.append(f"{prefix}: contrarian producer family가 grounding author family와 같음") tier = str(led.get("tier") or DEFAULT_TIER).lower() policy = divergent_policy(tier) minimum = policy.get("min-distinct-lenses") required = required_lenses(sorted(candidate_families), tier=tier, mode="divergent") if minimum == "all-relevant": if not required or not required.issubset(covered): errors.append(f"heavy all-relevant 렌즈 누락: {sorted(required - covered)}") elif len(covered) < int(minimum or 0): errors.append(f"distinct lens 부족: {len(covered)} < tier {tier} 최소 {minimum}") if policy.get("contrarian-required") and len(contrarian_reports) != 1: errors.append("standard/heavy는 정확히 하나의 LENS-CONTRARIAN contribution 필요") declared = body.get("lens-coverage") or {} if declared.get("required-min") != minimum: errors.append("lens-coverage.required-min이 governance tier 정책과 불일치") if set(declared.get("covered") or []) != covered: errors.append("lens-coverage.covered가 검증된 source contribution 렌즈와 불일치") expected_contrarian = contrarian_reports[0] if len(contrarian_reports) == 1 else None if declared.get("contrarian-report-id") != expected_contrarian: errors.append("lens-coverage.contrarian-report-id exact binding 불일치") workload = _workload_profile(arts) required_capabilities = {str(value).strip().lower() for value in workload.get("required-capabilities", []) or []} if ("competitive-intelligence" in required_capabilities and not any(str(item.get("producer-role-id") or "").upper() == "GTM-CI" for item in resolved.values())): errors.append("required-capability competitive-intelligence는 실제 GTM-CI source contribution이 필요") if _competitive_market_grounding_required(wf): market_ref = body.get("competitive-market-grounding-ref") or {} market_key = (str(market_ref.get("report-id") or ""), str(market_ref.get("report-sha256") or "")) market = trusted_by_revision.get(market_key) if (not market or market.get("artifact-kind") != "competitive-market-grounding" or str(market.get("producer-role-id") or "").upper() != "GTM-CI"): errors.append("공개형/신규/대규모 작업은 GTM-CI competitive-market-grounding exact report 필요") elif (market_key not in seen_reports or not _same_snapshot_ref(market_ref.get("report-ref"), market_key[1], market)): errors.append("competitive-market-grounding은 source-contributions에 포함되고 ref/SHA가 일치해야 함") return not errors, "; ".join(errors[:12]) if errors else None def _quality_panel_unmet(tier, arts, events): """Return missing independent reviewer requirements for the trusted workload risk.""" actors = {str(event.get("actor") or "").upper() for event in events} categories = {category for event in events for category in (event.get("check-categories") or [])} profile = _workload_profile(arts) surfaces = profile.get("surfaces") if isinstance(profile.get("surfaces"), dict) else {} risk = profile.get("risk") if isinstance(profile.get("risk"), dict) else {} unmet = [] if not actors.intersection({"QA", "EXEC-VPENG", "HUMAN-001"}): unmet.append("independent general quality auditor(QA/EXEC-VPENG/HUMAN-001)") if risk.get("security-bearing"): if not any(actor.startswith("SEC-") for actor in actors): unmet.append("independent security auditor") if "security" not in categories: unmet.append("security check category") if risk.get("data-migration") or surfaces.get("persistence"): if "ARCH-DATA" not in actors: unmet.append("independent data auditor(ARCH-DATA)") if "data-quality" not in categories: unmet.append("data-quality check category") if risk.get("privacy") and "privacy" not in categories: unmet.append("privacy check category") if risk.get("slo-impact") and "reliability" not in categories: unmet.append("reliability check category") if tier == "heavy" and len(actors) < 3: unmet.append("heavy tier minimum 3 independent verifiers") return unmet def _required_bundle_kinds(arts, bundle_name): bundle = (load_contracts().get("artifact-bundles", {}) or {}).get(bundle_name, {}) or {} required = set(bundle.get("always") or []) profile = _workload_profile(arts) surfaces = profile.get("surfaces") if isinstance(profile.get("surfaces"), dict) else {} risks = profile.get("risk") if isinstance(profile.get("risk"), dict) else {} signals = { "ui": bool(surfaces.get("ui")), "public-api": bool(surfaces.get("public-api")), "persistence": bool(surfaces.get("persistence")), "security-bearing": bool(risks.get("security-bearing")), "product-feature": profile.get("product-feature", True), } for condition in bundle.get("conditional") or []: if isinstance(condition, dict) and signals.get(condition.get("when")): required.update(condition.get("require") or []) return required def _bundle_accepted(wf, arts, bundle_name): accepted = _accepted_design_types(wf, arts) required = _required_bundle_kinds(arts, bundle_name) if not required or not required.issubset(accepted): return False latest = {} for artifact in arts or []: if isinstance(artifact, dict) and artifact.get("artifact-kind"): latest[artifact.get("artifact-kind")] = artifact components = [latest.get(kind) for kind in required] if any(not component for component in components): return False bases = {(component.get("basis-artifact-id"), component.get("basis-artifact-sha256")) for component in components} if len(bases) != 1 or next(iter(bases))[0] in (None, "") or next(iter(bases))[1] in (None, ""): return False target_kind = { "design-bundle": "executive-decision-packet", "spec-bundle": "overall-design", }.get(bundle_name) target = latest.get(target_kind) if not target: return False target_identity = (_report_id_of(target), target.get("artifact-sha256") or target.get("report-sha256")) if next(iter(bases)) != target_identity: return False if not _compatibility_reviews_ok(wf, arts, required, latest): return False try: import acceptance_log as AL # noqa: E402 return AL.is_effectively_accepted(wf, target_identity[0], target_identity[1]) except Exception: return False def _compatibility_reviews_ok(wf, arts, required, latest=None): """Require an exact typed compatibility verdict for every active contract pair.""" contracts = load_contracts().get("compatibility-contracts", []) or [] active = [item for item in contracts if item.get("left") in required and item.get("right") in required] if not active: return True if latest is None: latest = {} for artifact in arts or []: if isinstance(artifact, dict) and artifact.get("artifact-kind"): latest[artifact.get("artifact-kind")] = artifact reviews = [artifact for artifact in arts or [] if isinstance(artifact, dict) and artifact.get("artifact-kind") == "compatibility-review"] for contract in active: left = latest.get(contract.get("left")) right = latest.get(contract.get("right")) if not left or not right: return False expected = { contract.get("left"): (_report_id_of(left), left.get("artifact-sha256")), contract.get("right"): (_report_id_of(right), right.get("artifact-sha256")), } matched = False for review in reversed(reviews): content = _artifact_content(review, "compatibility-review") or {} endpoints = {} for side in ("left", "right"): value = content.get(side) or {} endpoints[value.get("artifact-kind")] = ( value.get("artifact-id"), value.get("artifact-sha256")) reviewer = str(content.get("reviewer-role-id") or "") if (endpoints == expected and content.get("verdict") == "Passed" and set(content.get("dimensions") or []) >= set(contract.get("dimensions") or []) and reviewer == str(review.get("producer-role-id") or "") and reviewer not in {str(left.get("producer-role-id") or ""), str(right.get("producer-role-id") or "")}): matched = True break if not matched: return False return True def _must_read_unmet(wf, led, arts): """spec→build gate derived from the canonical conditional bundles. The former family map used undeclared aliases such as ``design-system`` and ``service-boundary``. Those values could never be produced through the trusted artifact API and also created a second workload classifier. The accepted design/spec kinds now come solely from ``workflow-contracts`` and its trusted workload-profile conditions. """ required = (_required_bundle_kinds(arts, "design-bundle") | _required_bundle_kinds(arts, "spec-bundle")) accepted = _accepted_design_types(wf, arts) unmet = [] for kind in required: if kind not in accepted: unmet.append(kind) elif kind == "ui-design" and not _has_preview_receipt(wf): # UI design은 acceptance event가 있어도 실제 preview_ui 렌더 게이트 # receipt(exit 0)가 evidence-ledger 에 있어야 충족한다 — 렌더된 적 없는(산문만) design-system # 을 Accepted 로 위장해 프론트 BUILD 를 여는 것을 차단(docs-but-no-pixels 구멍 봉인). unmet.append("ui-design (preview_ui 렌더 게이트 receipt 없음 — 실제 렌더·품질검증 필요)") return sorted(unmet) def _method_handoff_unmet(wf, led, ctx): """P3-B: 전이 시 지정된 consumer profile 들의 required-inputs handoff 미충족이면 True(하드 게이트). opt-in: 전이 규칙/ctx 가 `handoff-check: [{role, method}]` 를 줄 때만 검사한다(무지정 → False, 무회귀). both-active 엣지 위반만 차단(method_contracts.handoff_violations), 한쪽 draft 는 debt. 수락 근사 = acceptance_log 의 from-role 최신 accepted 존재(Phase5 golden 에서 정밀화).""" checks = (ctx or {}).get("handoff-check") if isinstance(ctx, dict) else None if not checks: return False try: import method_contracts as _MC import acceptance_log as _AL except Exception: # noqa: BLE001 return False def _accepted(edge): frm = (edge.get("from") or {}).get("role-id") kind = edge.get("artifact-type") try: return bool(_AL.latest_accepted_artifact( wf, producer_role=frm, artifact_kind=kind)) except Exception: # noqa: BLE001 return False unmet = False for c in checks: try: errs, debts = _MC.handoff_violations(c.get("role"), c.get("method"), present=_accepted, accepted=_accepted, phase="transition") except Exception as exc: # noqa: BLE001 if led.get("tier") in ("standard", "heavy"): errs, debts = [f"method handoff policy 평가 실패(fail-closed): {exc}"], [] else: errs, debts = [], [] for d in debts: # T4.4: draft 엣지 부채 기록(fold 로 중복 무해). 정보용. try: _MC.record_debt(d) except Exception: # noqa: BLE001 pass if errs: unmet = True return unmet def _facts(wf, led, ctx=None): """원장 + acceptance_log + collaboration-map 에서 조건 평가용 사실 dict 를 파생한다. Protected gate facts always come from canonical events and live immutable snapshots. ``facts``/ctx overrides are retained only for non-security operational hints and cannot override protected facts. """ arts = led.get("artifacts") or [] explicit = dict(led.get("facts") or {}) if isinstance(ctx, dict): explicit.update(ctx.get("facts") or {}) f = {} f["tier"] = led.get("tier") or DEFAULT_TIER accepted_types = _accepted_design_types(wf, arts) # 발산 option-set은 grounding-package의 검증된 본문에서만 파생한다. opts = [] for artifact in arts: if isinstance(artifact, dict) and artifact.get("artifact-kind") == "grounding-package": opts = artifact.get("option-set") or [] f["option_set_count"] = len(opts) if isinstance(opts, list) else 0 # blocker f["blocker_open"] = bool(led.get("blocker-open")) # These fields are projected exclusively from workflow events. f["quality_gate_status"] = led.get("quality_gate_status") f["quality_gate_failed"] = led.get("quality_gate_status") == "Failed" f["release_acceptance_status"] = led.get("release_acceptance_status") f["unresolved_critical_risks"] = bool( led.get("unresolved_critical_risks") or led.get("unresolved-critical-risks") ) f["human_gate_approved"] = bool(_human_gate_satisfied(wf, led.get("stage"))) f["evidence_grade"] = None latest_packet = next((artifact for artifact in reversed(arts) if isinstance(artifact, dict) and artifact.get("artifact-kind") == "executive-decision-packet"), None) if latest_packet and "executive-decision-packet" in accepted_types: grade = latest_packet.get("max-evidence-grade") if grade in _EGRADE: f["evidence_grade"] = grade # 산출물 존재 플래그(design-type 또는 원장 플래그) f["decision_brief_present"] = _present(led, arts, ["decision-brief"], "decision-brief-present") f["workload_profile_present"] = _present(led, arts, ["workload-profile"], "workload-profile-present") # --- venture-bootstrap facts (P1) --- f["founder_context_present"] = _founder_context_filled() # caller가 workflow.yaml/ctx에 리스트를 자기신고해도 게이트 사실이 되지 않는다. 전역 artifact # event 원장에서 live id+sha가 검증된 snapshot만 읽고, 같은 payload.id 복제도 한 개로 센다. f["opportunity_cluster_count"] = _distinct_opportunity_cluster_count(_trusted_artifacts(wf)) f["venture_options_validated"] = bool(accepted_types & {"venture-validation", "venture-option"}) f["venture_decision_accepted"] = bool(accepted_types & {"venture-decision", "ExecutiveDecisionPacket", "decision-packet"}) f["human_acceptance_receipt_present"] = _venture_decision_receipt_ok(wf) _st = _company_ctx_status() f["company_context_provisional_committed"] = _st in ("provisional", "operating") f["company_context_lint_passed"] = _company_ctx_lint_ok() f["company_context_artifact_recorded"] = _company_ctx_artifact_recorded(arts) # A repository-scoped product change must not be forced through company formation. # The exception is narrow and trusted: only the canonical workload-profile may # declare context-scope=project, and it must also declare product-feature=true. # This opens delivery against the named project; it does not create company facts, # market validation, or an operating company context. workload = next((artifact.get("workload-profile") for artifact in reversed(arts) if isinstance(artifact, dict) and artifact.get("artifact-kind") == "workload-profile" and isinstance(artifact.get("workload-profile"), dict)), None) f["project_context_scoped"] = bool( workload and workload.get("context-scope") == "project" and workload.get("product-feature") is True) f["company_context_ready"] = bool( (f.get("company_context_provisional_committed") or f.get("project_context_scoped")) and not f.get("blocker_open")) # --- experience-foundation facts (front-of-funnel experience grounding) --- f["experience_parent_binding_present"] = bool( led.get("plan") == "experience-foundation" and led.get("parent-workflow-id") and led.get("product-decision-id") and _product_decision_current(led.get("parent-workflow-id"), led.get("product-decision-id"))) f["competitive_benchmark_accepted"] = bool( _latest_accepted_artifact(wf, arts, "competitive-experience-benchmark")) _strategy = _latest_accepted_artifact(wf, arts, "experience-strategy") f["experience_strategy_accepted"] = bool( _strategy and (_artifact_content(_strategy, "experience-strategy") or {}).get("decision") == "proceed") _experience_parent_id = led.get("parent-workflow-id") if led.get("plan") == "experience-foundation" else None _experience_pd = led.get("product-decision-id") if led.get("plan") == "experience-foundation" else None f["experience_technical_feasibility_accepted"] = bool( _strategy and _experience_parent_id and _accepted_experience_feasibility( wf, _experience_parent_id, _experience_pd, _strategy, "experience-technical-feasibility")) f["experience_operational_feasibility_accepted"] = bool( _strategy and _experience_parent_id and _accepted_experience_feasibility( wf, _experience_parent_id, _experience_pd, _strategy, "experience-operational-feasibility")) f["experience_blueprint_accepted"] = bool( _latest_accepted_artifact(wf, arts, "experience-blueprint")) f["wireframe_set_accepted"] = bool(_latest_accepted_artifact(wf, arts, "wireframe-set")) f["experience_foundation_link_recorded"] = bool(_has_experience_foundation(wf, led)) _experience_parent = (led.get("parent-workflow-id") if led.get("plan") == "design-direction" else wf) _experience_parent_led = (_load_ledger_safe(_experience_parent) if _experience_parent != wf else led) f["experience_foundation_required"] = _experience_foundation_required(_experience_parent) f["experience_foundation_gate_satisfied"] = bool( not f["experience_foundation_required"] or _has_experience_foundation(_experience_parent, _experience_parent_led)) # --- design-direction facts (P2) --- f["parent_binding_present"] = bool(led.get("parent-workflow-id") and led.get("product-decision-id") and _dd_input_brief_ref(led)) try: import lint_design_direction as _LDD _ib = _dd_input_brief_ref(led) _ibp = _ib if (_ib and os.path.isabs(_ib)) else (os.path.join(ROOT, _ib) if _ib else None) f["direction_input_brief_valid"] = bool(_ibp and os.path.exists(_ibp) and not _LDD.lint_file(_ibp, "direction-input-brief")[0]) except Exception: f["direction_input_brief_valid"] = False f["direction_discovery_present"] = bool(_active_artifact(led, arts, "direction-discovery", "direction-discovery-report-id")) f["divergence_charter_present"] = _divergence_charter_ok(led, arts) f["directions_diverged"] = _directions_diverged(led, arts) f["divergence_audit_passed"] = _divergence_audit_ok(wf, led, arts) f["selected_direction_accepted"] = _selected_direction_accepted_ok(wf, led, arts) f["none_of_the_above_recorded"] = _none_of_the_above_recorded(wf, led, arts) _wp = _active_artifact(led, arts, "winner-prototype", "prototype-report-id") _wp_content = _artifact_content(_wp, "winner-prototype") f["winner_prototype_present"] = bool(_wp and _wp_content and _wp_content.get("preview-receipt-ref")) and _has_preview_receipt(wf, _wp_content) _v = _panel_verdict(led, arts) f["critique_revision_requested"] = (_v == "minor-revision") f["concept_rejection_recorded"] = (_v == "concept-flaw") f["direction_critique_passed"] = _critique_panel_ok(led, arts) and _has_preview_receipt(wf, _wp_content) f["design_direction_approved"] = _has_direction_approval(wf, led) # Task 11 f["_ui_bearing"] = _is_ui_bearing(led, wf) # Task 13/Fix A: 부모 cascade design→spec gate f["method_handoff_unmet"] = _method_handoff_unmet(wf, led, ctx) # P3-B T4.3: 전이 handoff 게이트 f["grounding_evidence_present"] = _present(led, arts, ["grounding-package"], "grounding-evidence-present") _grounding_ok, _grounding_reason = _grounding_lens_coverage(wf, led, arts) f["grounding_lens_coverage_satisfied"] = _grounding_ok f["_grounding_lens_coverage_reason"] = _grounding_reason f["wave_plan_present"] = _present(led, arts, ["wave-plan", "plan", "wave_plan"], "wave-plan-present") f["completion_record_present"] = _present(led, arts, ["completion-record"], "completion-record-present") f["blocked_report_present"] = _present(led, arts, ["blocked-report"], "blocked-report-present") # 승인(Accepted) 파생 f["design_accepted"] = _bundle_accepted(wf, arts, "design-bundle") f["design_system_release_attached"] = bool( not _experience_foundation_required(wf) or _ui_design_release_binding_ok(arts)) f["spec_accepted"] = _bundle_accepted(wf, arts, "spec-bundle") # No fallback: only the exact current executive-decision-packet revision. f["decision_packet_accepted"] = "executive-decision-packet" in accepted_types # spec→build 핵심 게이트 f["_must_read_unmet"] = _must_read_unmet(wf, led, arts) # 재개(blocked) f["resume_condition_present"] = bool( led.get("resume-condition") or led.get("resume_condition_present") or led.get("resume-condition-satisfied") or led.get("resume_condition_satisfied") ) f["resume_condition_satisfied"] = bool( led.get("resume-condition-satisfied") or led.get("resume_condition_satisfied") ) f["human_instruction_needed"] = bool(led.get("human-instruction-needed") or led.get("human_instruction_needed")) f["human_instruction_applied"] = bool(led.get("human-instruction-applied") or led.get("human_instruction_applied")) # Magentic run 루프 prog = led.get("progress") or {} limits = (load_tiers().get("governance-limits", {}) or {}) max_rounds = limits.get("max-rounds", 12) max_stalls = limits.get("max-stalls", 3) rnd = prog.get("round", 0) or 0 stalls = prog.get("stall_count", prog.get("stall-count", 0)) or 0 ipbm = prog.get("is_progress_being_made", prog.get("is-progress-being-made", True)) f["progress_ok"] = bool(ipbm) and rnd < max_rounds and stalls < max_stalls f["_progress_detail"] = f"round={rnd}/{max_rounds}, stalls={stalls}/{max_stalls}, progressing={ipbm}" # evidence-grade vs tier 최소치 f["_tier_evidence_min"] = _tier_evidence_min(f["tier"]) # 명시적 오버라이드 병합 — 단, **신뢰 게이트(PROTECTED)는 오버라이드 불가**(finding P0-4). # 이들은 실제 accepted 아티팩트(acceptance_log)·signoff 파일·산출물 실존에서만 파생한다 — # 원장 facts 에 손으로 적은 값이 게이트를 통과시키지 못하게 한다(원장은 guard 보호이지만 # 심층방어로 파생값을 되살린다). 나머지 운영 facts(progress/tier 등)만 오버라이드 허용. for k, v in explicit.items(): nk = _normkey(k) if nk in _PROTECTED_FACTS: continue f[nk] = v return f # 신뢰 게이트에 쓰이는 파생 사실 — 원장 명시 facts 로 오버라이드 금지(P0-4). _PROTECTED_FACTS = { "design_accepted", "design_system_release_attached", "spec_accepted", "decision_packet_accepted", "human_gate_approved", "completion_record_present", "quality_gate_status", "quality_gate_failed", "release_acceptance_status", "evidence_grade", "_must_read_unmet", "grounding_evidence_present", "wave_plan_present", "decision_brief_present", "grounding_lens_coverage_satisfied", "_grounding_lens_coverage_reason", "workload_profile_present", "blocker_open", "founder_context_present", "venture_options_validated", "venture_decision_accepted", "human_acceptance_receipt_present", "company_context_provisional_committed", "company_context_lint_passed", "company_context_artifact_recorded", "company_context_ready", "experience_parent_binding_present", "competitive_benchmark_accepted", "experience_strategy_accepted", "experience_technical_feasibility_accepted", "experience_operational_feasibility_accepted", "experience_blueprint_accepted", "wireframe_set_accepted", "experience_foundation_link_recorded", "experience_foundation_required", "experience_foundation_gate_satisfied", "parent_binding_present", "direction_input_brief_valid", "direction_discovery_present", "divergence_charter_present", "directions_diverged", "divergence_audit_passed", "selected_direction_accepted", "none_of_the_above_recorded", "winner_prototype_present", "critique_revision_requested", "concept_rejection_recorded", "direction_critique_passed", "design_direction_approved", "_ui_bearing", "method_handoff_unmet", } def _normkey(k): return str(k).replace("-", "_") # ---------------------------------------------------------------- condition predicates def _p_evidence_grade(f): grade = f.get("evidence_grade") minv = f.get("_tier_evidence_min") if not minv: return (True, None) # tier 에 최소 증거등급 없음 -> 게이트 없음 # finding P0-4: tier 가 최소치를 요구하는데 원장에 evidence-grade 가 없으면 **차단**한다. # 예전엔 미기재를 '측정 불가'로 통과시켜, standard+ tier 에서 증거등급 게이트가 무력화됐다. if not grade: return (False, f"승인된 executive-decision-packet의 max evidence grade 없음 — " f"tier 최소 {minv} 충족을 증명할 수 없다(submit/review된 report evidence에서만 파생).") if _EGRADE.get(str(grade), -1) >= _EGRADE.get(str(minv), 99): return (True, None) return (False, f"증거등급 부족: {grade} < tier 최소 {minv}") def _p_human_gate(f): if _tier_human_gate_required(f.get("tier")): if f.get("human_gate_approved"): return (True, None) return (False, "heavy tier: 사람 승인(human-gate) 필요 — human_gate_approved=false") return (True, None) def _p_must_read(f): unmet = f.get("_must_read_unmet") or [] if unmet: return (False, "must-read 설계 미승인(Accepted 아님): " + ", ".join(unmet)) return (True, None) def _p_progress(f): if f.get("progress_ok"): return (True, None) return (False, "run 루프 진전 없음/상한 초과: " + str(f.get("_progress_detail"))) def _p_human_instr(f): if f.get("human_instruction_needed") and not f.get("human_instruction_applied"): return (False, "사람 지시 필요하나 미적용(human-instruction-applied=false)") return (True, None) _PREDICATES = { "decision-brief-present": lambda f: (bool(f.get("decision_brief_present")), "decision-brief(intake 산출물) 없음"), "workload-profile-present": lambda f: (bool(f.get("workload_profile_present")), "workload-profile(intake typed 산출물) 없음"), "grounding-evidence-present": lambda f: (bool(f.get("grounding_evidence_present")), "discovery 근거 접지 산출물 없음"), "option-set-present": lambda f: (f.get("option_set_count", 0) >= 2, f"option-set ≥2 필요(현재 {f.get('option_set_count', 0)})"), "grounding-lens-coverage-satisfied": lambda f: ( bool(f.get("grounding_lens_coverage_satisfied")), f.get("_grounding_lens_coverage_reason") or "grounding lens/source binding 미충족", ), "decision-packet-accepted": lambda f: (bool(f.get("decision_packet_accepted")), "ExecutiveDecisionPacket 미승인(Accepted 아님)"), "evidence-grade-min": _p_evidence_grade, "design-accepted": lambda f: (bool(f.get("design_accepted")), "설계 산출물 미승인(Accepted 아님)"), "design-system-release-attached": lambda f: ( bool(f.get("design_system_release_attached")), "공개/신규/대규모 UI의 ui-design에 candidate|stable 조직 design release exact ref/SHA+subset+delta 없음"), "spec-accepted": lambda f: (bool(f.get("spec_accepted")), "기능명세 미승인(Accepted 아님)"), "must-read-designs-accepted": _p_must_read, "completion-record-present": lambda f: (bool(f.get("completion_record_present")), "completion-record 없음"), "quality-gate-passed": lambda f: (f.get("quality_gate_status") == "Passed", f"quality_gate_status != Passed (현재 {f.get('quality_gate_status')})"), "quality-gate-failed": lambda f: (bool(f.get("quality_gate_failed")), f"quality_gate_status != Failed (현재 {f.get('quality_gate_status')})"), "blocker-open-false": lambda f: (not f.get("blocker_open"), "열린 blocker 존재"), "release-approved": lambda f: (f.get("release_acceptance_status") == "Approved", f"release_acceptance_status != Approved (현재 {f.get('release_acceptance_status')})"), "no-unresolved-critical-risks": lambda f: (not f.get("unresolved_critical_risks"), "미해결 Critical 리스크 존재"), "human-gate": _p_human_gate, "wave-plan-present": lambda f: (bool(f.get("wave_plan_present")), "wave plan 산출물 없음"), "progress-being-made": _p_progress, "blocked-report-present": lambda f: (bool(f.get("blocked_report_present")), "BlockedReport 없음"), "resume-condition-present": lambda f: (bool(f.get("resume_condition_present")), "재개 조건 미명시"), "resume-condition-satisfied": lambda f: (bool(f.get("resume_condition_satisfied")), "재개 조건 미충족"), "human-instruction-applied-if-needed": _p_human_instr, "founder-context-present": lambda f: (bool(f.get("founder_context_present")), "founder-context.yaml status != filled"), "opportunity-clusters-present": lambda f: (f.get("opportunity_cluster_count", 0) >= 2, f"opportunity-cluster ≥2 필요(현재 {f.get('opportunity_cluster_count', 0)})"), "venture-options-validated": lambda f: (bool(f.get("venture_options_validated")), "venture-validation accepted 산출물 없음(kill-criteria 포함 옵션 검증 필요)"), "venture-decision-accepted": lambda f: (bool(f.get("venture_decision_accepted")), "venture-decision accepted 산출물 없음"), "human-acceptance-receipt-present": lambda f: (bool(f.get("human_acceptance_receipt_present")), "HUMAN-001 accepted 이벤트(report-sha256 바인딩) 없음 — boolean 자기신고 불가"), "company-context-provisional-committed": lambda f: (bool(f.get("company_context_provisional_committed")), "공식 company-context.yaml status != provisional/operating"), "company-context-lint-passed": lambda f: (bool(f.get("company_context_lint_passed")), "company-context lint Hard Fail 존재"), "company-context-artifact-recorded": lambda f: (bool(f.get("company_context_artifact_recorded")), "company-context commit receipt(artifact) 없음"), "company-context-ready": lambda f: ( bool(f.get("company_context_ready")), "company-context 미준비(status provisional/operating 또는 trusted project-scoped workload 필요; blocker 없어야 함)"), "experience-parent-binding-present": lambda f: ( bool(f.get("experience_parent_binding_present")), "experience-foundation parent/product-decision exact binding 없음"), "competitive-benchmark-accepted": lambda f: ( bool(f.get("competitive_benchmark_accepted")), "competitive-experience-benchmark exact revision 미승인"), "experience-strategy-accepted": lambda f: ( bool(f.get("experience_strategy_accepted")), "experience-strategy proceed exact revision 미승인"), "experience-technical-feasibility-accepted": lambda f: ( bool(f.get("experience_technical_feasibility_accepted")), "현재 strategy exact ref/SHA에 결속된 CTO/CPTO technical feasibility=feasible revision 미승인"), "experience-operational-feasibility-accepted": lambda f: ( bool(f.get("experience_operational_feasibility_accepted")), "현재 strategy exact ref/SHA에 결속된 COO operational feasibility=feasible revision 미승인"), "experience-blueprint-accepted": lambda f: ( bool(f.get("experience_blueprint_accepted")), "experience-blueprint exact revision 미승인"), "wireframe-set-accepted": lambda f: ( bool(f.get("wireframe_set_accepted")), "wireframe-set exact revision 미승인"), "experience-foundation-link-recorded": lambda f: ( bool(f.get("experience_foundation_link_recorded")), "부모 experience-foundation exact bundle 링크 미기록/스테일"), "experience-foundation-gate-satisfied": lambda f: ( bool(f.get("experience_foundation_gate_satisfied")), "공개 웹/interactive-learning/신규 제품/대규모 리디자인: approved experience-foundation 선행 필요"), "parent-binding-present": lambda f: (bool(f.get("parent_binding_present")), "parent 바인딩 없음"), "direction-input-brief-valid": lambda f: (bool(f.get("direction_input_brief_valid")), "input-brief lint 실패(필수/금지)"), "direction-discovery-present": lambda f: (bool(f.get("direction_discovery_present")), "direction-discovery 아티팩트 없음"), "divergence-charter-present": lambda f: (bool(f.get("divergence_charter_present")), "divergence-charter 부재/직교 분할 lint 실패"), "directions-diverged": lambda f: (bool(f.get("directions_diverged")), "3안 독립성/실픽셀/비교렌더 실패"), "divergence-audit-passed": lambda f: (bool(f.get("divergence_audit_passed")), "선택 전 비교감사 미통과(쌍별 4축 차이/full-size preview/primitive collision/blocker/승인 확인)"), "selected-direction-accepted": lambda f: (bool(f.get("selected_direction_accepted")), "selected bundle lint 실패 또는 미승인"), "none-of-the-above-recorded": lambda f: ( bool(f.get("none_of_the_above_recorded")), "HUMAN-001 none-of-the-above exact decision 없음"), "winner-prototype-present": lambda f: (bool(f.get("winner_prototype_present")), "winner-prototype/preview receipt 없음"), "critique-revision-requested": lambda f: (bool(f.get("critique_revision_requested")), "active verdict != minor-revision"), "concept-rejection-recorded": lambda f: (bool(f.get("concept_rejection_recorded")), "active verdict != concept-flaw"), "direction-critique-passed": lambda f: (bool(f.get("direction_critique_passed")), "패널 pass 미충족"), "approved-direction-valid": lambda f: (bool(f.get("design_direction_approved")), "approved-direction 8점 검증 실패(불변 report/schema/hash 불일치)"), "approval-receipt-bound": lambda f: (bool(f.get("design_direction_approved")), "acceptance receipt 가 child workflow+report-sha256 에 정확 바인딩되지 않음"), "parent-approval-link-recorded": lambda f: (bool(f.get("design_direction_approved")), "부모 원장 design-direction-approval(report-ref/sha256/child-workflow-id) 미기록"), "design-direction-approved": lambda f: (bool(f.get("design_direction_approved")), "approved-direction 8점/staleness 실패 — 자기신고 불가"), "design-direction-gate-satisfied": lambda f: ( (not f.get("_ui_bearing")) or f.get("tier") not in ("standard", "heavy") or bool(f.get("design_direction_approved")), "UI-bearing standard/heavy: 승인된 design-direction 필요(우회 차단)"), "method-handoff-satisfied": lambda f: ( not f.get("method_handoff_unmet"), "전이 handoff 미충족: consumer required-inputs(both-active) 부재/미수락(Accepted 필요)"), } def _cmp(actual, expected): if isinstance(expected, bool): return bool(actual) == expected if isinstance(expected, str) and expected.lower() in ("true", "false"): return bool(actual) == (expected.lower() == "true") return str(actual) == str(expected) def _eval_condition(cond, facts): """조건 하나 평가 -> (ok, reason_if_false). 문자열(술어) 또는 {key: expected} 형식 지원.""" if isinstance(cond, dict): for k, v in cond.items(): actual = facts.get(_normkey(k)) if not _cmp(actual, v): return (False, f"{k} != {v} (실제: {actual})") return (True, None) key = str(cond) pred = _PREDICATES.get(key) if pred is None: return (False, f"미지의 조건: {key}") try: return pred(facts) except Exception as e: return (False, f"조건 평가 오류({key}): {e}") # ---------------------------------------------------------------- transition lookup def _find_transition(frm, to): """(frm, to) 에 해당하는 전이 규칙. 와일드카드(*->blocked)·재개(blocked->) 해석 포함.""" ws = _ws_transitions() for t in ws: if t.get("from") == frm and t.get("to") == to: return t if frm == "blocked": for t in ws: if t.get("from") == "blocked" and t.get("to") == "": return t if to == "blocked": for t in ws: if t.get("from") == "*" and t.get("to") == "blocked": return t return None # ---------------------------------------------------------------- public API def current_stage(wf): """현재 stage(원장 없으면 초기 stage intake). 부작용 없음. 예외 없음.""" try: return _load_ledger_safe(wf).get("stage", INITIAL_STAGE) except Exception as e: _log(f"current_stage 오류: {e}") return INITIAL_STAGE def allowed_next(wf): """현재 stage 에서 구조적으로 도달 가능한 다음 stage 목록(조건 미검사). 예외 없음.""" try: led = _load_ledger_safe(wf) cur = led.get("stage", INITIAL_STAGE) plan = led.get("plan", DEFAULT_PLAN) stages = _plan_stages(plan) return _transition_engine.structural_next( cur, stages, _ws_transitions(), led.get("blocked-from") ) except Exception as e: _log(f"allowed_next 오류: {e}") return [] def _actor_allowed(t, actor): """Require a concrete registered transition executor. Runtime placeholders are documentation bugs, not authorization grants. Decision authors/reviewers are authorized by review-artifact separately. """ return _transition_engine.actor_allowed(t, actor, _role_registry()) def can_transition(wf, to, ctx=None, actor=None): """(ok, [unmet_reason]). required-conditions/forbidden-if 를 원장 사실로 검사. 예외 없음.""" try: led = _load_ledger_safe(wf) frm = led.get("stage", INITIAL_STAGE) t = _find_transition(frm, to) if not t: return (False, [f"정의된 전이 규칙 없음: {frm} -> {to}"]) plan = led.get("plan", DEFAULT_PLAN) plan_stages = set(_plan_stages(plan)) facts = _facts(wf, led, ctx) reasons = _transition_engine.evaluate_transition( t, current=frm, destination=to, plan=plan, plan_stages=plan_stages, blocked_from=led.get("blocked-from"), facts=facts, condition_evaluator=_eval_condition, actor=actor, role_registry=_role_registry(), ) return (len(reasons) == 0, reasons) except Exception as e: _log(f"can_transition 오류: {e}") return (False, [f"can_transition 내부 오류(degrade): {e}"]) def enter_stage(wf, to, evidence=None, actor=None, ctx=None): """완료된 current stage에서 ``to`` stage를 running으로 연다. 실제 전이는 actor 를 명시해야 한다(권한 allowed-by 검사 + 감사). guard/check (전제조건 미리보기)와 달리 enter-stage는 actor 미지정 시 거부한다. blocked 진입/재개는 작업 완료와 무관한 side-state이므로 stage-status 검사를 적용하지 않는다.""" if not str(actor or "").strip(): return (False, ["전이 주체(--actor) 미지정 — 전이는 actor 를 명시해야 한다(allowed-by 권한/감사, P0-4)."]) ok, reasons = can_transition(wf, to, ctx, actor=actor) if not ok: return (False, reasons) try: with _workflow_lock(wf): led = read_ledger(wf) if led is None: led = _default_ledger(wf) frm = led.get("stage", INITIAL_STAGE) if (led.get("stage-status") == "running" and frm != "blocked" and to != "blocked" and to != frm): return (False, [f"현재 stage '{frm}'가 running이다 — complete-stage 후 '{to}'에 진입하라"]) intended = led.get("completed-for-next-stage") if (led.get("stage-status") == "completed" and intended and to != intended): return (False, [f"stage '{frm}'는 '{intended}' 진입용으로 완료됐다(요청: {to})"]) event = { "state-event-id": f"se-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "state-transition", "workflow-id": wf, "from": frm, "to": to, "actor": actor, "evidence": evidence, "effective-at": _now(), } if not _append_state_event(wf, event): return (False, ["workflow event append 실패"]) led["stage"] = to led["stage-status"] = "running" led["last-completed-stage"] = frm led["completed-for-next-stage"] = None if to == "blocked": led["blocked-from"] = frm elif frm == "blocked": led.pop("blocked-from", None) led["last-updated-at"] = _now() _write_ledger(wf, led) return (True, []) except Exception as e: _log(f"enter_stage 오류: {e}") return (False, [f"enter-stage 내부 오류(degrade): {e}"]) def complete_stage(wf, actor, evidence=None, to=None): """Validate the current stage's exit gate and mark it completed.""" if actor != "OPS-ORCH" or not _role_has_capability(actor, "transition-executor"): return False, ["complete-stage는 OPS-ORCH transition-executor만 실행 가능"] led = read_ledger(wf) if not led: return False, [f"workflow 원장 없음: {wf}"] stage = led.get("stage") if led.get("stage-status") == "completed": return True, [] plan = led.get("plan", DEFAULT_PLAN) stages = _plan_stages(plan) next_stage = str(to).strip() if to else None if next_stage and next_stage not in allowed_next(wf): return False, [f"현재 stage '{stage}'에서 완료 대상으로 '{next_stage}'를 선택할 수 없다"] if not next_stage and stage in stages: index = stages.index(stage) if index + 1 < len(stages): next_stage = stages[index + 1] if next_stage: ok, reasons = can_transition(wf, next_stage) if not ok: return False, reasons event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "stage-completed", "workflow-id": wf, "stage": stage, "actor": actor, "evidence": evidence, "intended-next-stage": next_stage, "effective-at": _now(), } committed, error = _atomic_event_transaction(wf, workflow_event=event) return (True, []) if committed else (False, [error]) def transition(wf, to, evidence=None, actor=None, ctx=None): """Deprecated compatibility advance: complete current, then enter ``to``. 신규 command/runtime은 ``complete_stage``와 ``enter_stage``를 분리해 호출한다. 과거 ``transition`` 호출은 gate fact를 우회하지 않도록 현재 exit gate를 먼저 검증·완료한 뒤 같은 권한 검사로 다음 stage를 연다. blocked side-state는 완료를 요구하지 않는다. """ led = read_ledger(wf) if not led: return False, [f"workflow 원장 없음: {wf}"] frm = led.get("stage", INITIAL_STAGE) if to == "blocked" or frm == "blocked" or to == frm: return enter_stage(wf, to, evidence=evidence, actor=actor, ctx=ctx) if led.get("stage-status", "running") != "completed": ok, reasons = complete_stage(wf, actor, evidence=evidence, to=to) if not ok: return ok, reasons return enter_stage(wf, to, evidence=evidence, actor=actor, ctx=ctx) def update_progress(wf, updates, plan=None): """wave/light 의 Magentic progress 필드를 통합 원장 progress: 하위에 병합한다. E2 통합(#7): plan-wave/run-wave 가 별도 progress.yaml 대신 이 원장의 progress: 를 읽고 쓴다 (하나의 wf-id, 하나의 원장). stage 는 바꾸지 않는다(전진은 transition 이 담당). 원장이 없으면 생성(plan 기본값). (ok, progress_dict) 반환. 예외를 던지지 않는다(degrade-safe). keys 는 전달된 그대로 보존한다(round/stall_count/is_progress_being_made/next 등) — _facts 가 underscore/hyphen 두 표기를 모두 읽으므로 정규화하지 않는다.""" try: led = read_ledger(wf) if led is None: led = _default_ledger(wf, plan=plan or DEFAULT_PLAN) elif plan and not led.get("plan"): led["plan"] = plan prog = dict(led.get("progress") or {}) if isinstance(updates, dict): for k, v in updates.items(): if v is not None: prog[k] = v led["progress"] = prog led["last-updated-at"] = _now() ok = _write_ledger(wf, led) return (ok, prog) except Exception as e: _log(f"update_progress 오류: {e}") return (False, {}) def read_progress(wf): """통합 원장의 progress: dict(없으면 {}). 예외 없음.""" try: return dict((_load_ledger_safe(wf).get("progress") or {})) except Exception as e: _log(f"read_progress 오류: {e}") return {} # ---------------------------------------------------------------- orchestrator (항목4) # stage -> 그 stage 의 작업을 수행하는 cascade 커맨드. released/closed 는 종단(커맨드 없음). _STAGE_COMMAND = { "intake": "/ceo-intake", "discovery": "/ground", "decide": "/decide", "design": "/design", "spec": "/spec", "build": "/build", "verification": "/review-output", "acceptance": "/release-check", "released": None, "closed": None, # wave/light 내부 stage "plan": "/plan-wave", "run": "/run-wave", } def _stage_command(plan, stage): definition = (((load_contracts().get("workflows", {}) or {}).get(plan, {}) or {}) .get("stages", {}).get(stage, {})) if isinstance(definition, dict) and "command" in definition: command = definition.get("command") return f"/{command}" if command else None return _STAGE_COMMAND.get(stage) # 사람 결정(DRAI decider=human)이 개입하는 전이 조건 — /run-cascade 는 여기서 멈춰 사람 수용을 받는다. _HUMAN_GATE_CONDS = { "human-gate": "릴리스 수용(release acceptance) — 사람 최종 승인", "release-approved": "릴리스 승인", "decision-packet-accepted": "go/no-go 의사결정 수용(방향 확정)", } def _human_gate_for(frm, to): """(frm->to) 전이가 사람 게이트를 요구하나 -> (required, approver, what|None). 전이 규칙의 required-conditions 에 DRAI human-decider 조건(_HUMAN_GATE_CONDS)이 있으면 사람 승인 지점이다. approver 는 governance-tiers plan-signoff/human-gate 기준 HUMAN-001(위임 시 EXEC-CEO). 엔진의 하드 강제는 heavy tier 의 human-gate(signoff 파일)뿐이고, decide go/no-go 는 오케스트레이터가 멈춰 사람 수용을 받는 pause 지점이다(자동 승인·자동 완주 금지 — 항목4 불변식).""" t = _find_transition(frm, to) if not t: return (False, None, None) hits = [c for c in (t.get("required-conditions") or []) if isinstance(c, str) and c in _HUMAN_GATE_CONDS] if not hits: return (False, None, None) what = "; ".join(_HUMAN_GATE_CONDS[c] for c in hits) return (True, "HUMAN-001 (위임 시 EXEC-CEO)", what) def next_info(wf): """오케스트레이터(/run-cascade)용 결정론적 다음-스텝 계산. 예외 없음. state graph(can_transition/allowed_next)를 그대로 재사용한다 — 평행 엔진이 아니라 얇은 조회층. 반환 dict: current-stage/current-command : 현 stage 와 그 작업 커맨드(작업 미완이면 이걸 실행) next-stage/next-command : plan 시퀀스상 다음 stage 와 커맨드 advance{ok,reasons,human-gate} : cur->next 전이의 guard 결과 + 사람 게이트 여부/승인자/사유 terminal : 종단 도달 """ try: led = _load_ledger_safe(wf) cur = led.get("stage", INITIAL_STAGE) plan = led.get("plan", DEFAULT_PLAN) tier = led.get("tier", DEFAULT_TIER) stage_status = led.get("stage-status", "running") stages = _plan_stages(plan) terminal = _plan_terminal(plan) nxt = None if cur == "blocked": nxt = led.get("blocked-from") elif cur in stages: i = stages.index(cur) if i + 1 < len(stages): nxt = stages[i + 1] terminal_stage = (cur == terminal) or (cur in ("released", "closed")) or ( nxt is None and cur != "blocked") is_terminal = bool(terminal_stage and stage_status == "completed") info = { "workflow-id": wf, "plan": plan, "tier": tier, "current-stage": cur, "stage-status": stage_status, "last-completed-stage": led.get("last-completed-stage"), "current-command": _stage_command(plan, cur) if stage_status == "running" else None, "next-stage": nxt, "next-command": _stage_command(plan, nxt) if nxt else None, "terminal": bool(is_terminal), } if nxt: ok, reasons = can_transition(wf, nxt) hg_req, approver, what = _human_gate_for(cur, nxt) # 사람 signoff 가 이미 있으면(사람이 세션 밖에서 승인) 게이트 해제 → 재개 가능. if hg_req and _human_gate_satisfied(wf, cur): hg_req = False info["advance"] = { "ok": bool(ok), "reasons": reasons, "human-gate": {"required": bool(hg_req), "approver": approver, "what": what}, } return info except Exception as e: _log(f"next_info 오류: {e}") return {"workflow-id": wf, "error": str(e)} # ---------------------------------------------------------------- CLI def _argval(args, flag, default=None): return args[args.index(flag) + 1] if flag in args and args.index(flag) + 1 < len(args) else default def _coerce(s): """CLI 값 문자열을 bool/int 로 최대한 변환(progress 필드 타입 보존).""" if isinstance(s, str): low = s.strip().lower() if low in ("true", "false"): return low == "true" try: return int(s) except (TypeError, ValueError): return s return s def _workspace_ok(): return _state_dir(create=False) is not None def main(): args = sys.argv[1:] if not args: sys.stderr.write(__doc__) return 1 cmd = args[0] wf = _argval(args, "--workflow") to = _argval(args, "--to") if cmd == "guard": if not wf or not to: sys.stderr.write("usage: state_engine.py guard --workflow WF --to STAGE\n") return 2 if not _workspace_ok(): # fail-CLOSED(finding P0-1): 상태 판별 불가면 통과시키지 않는다. 과거엔 # degrade(allow)라서 workspace 한 줄만 비우면 상태 게이트가 fail-open 됐다. sys.stderr.write( "[state_engine] BLOCK guard: workspace 미설정 — 상태 검증 불가로 전이 거부(exit 2). " "ORGOS_WORKSPACE= 를 설정하세요.\n") return 2 ok, reasons = can_transition(wf, to) if ok: return 0 cur = current_stage(wf) sys.stderr.write(f"[state_engine] BLOCK 전이 {cur} -> {to}: 선행조건 미충족\n") for r in reasons: sys.stderr.write(f" - {r}\n") return 2 if cmd == "check-company-context-ready": if not wf: sys.stderr.write("usage: state_engine.py check-company-context-ready --workflow WF\n") return 1 f = _facts(wf, _load_ledger_safe(wf)) ok, reason = _eval_condition("company-context-ready", f) if ok: print("[state_engine] company-context READY") return 0 sys.stderr.write(f"[state_engine] NOT READY: {reason}\n") return 2 if cmd == "next": # 항목4: /run-cascade 용 결정론적 다음-스텝 조회(정보용, exit 0). guard/transition 을 # 재사용하는 얇은 조회층 — 사람 게이트(human-gate.required)는 오케스트레이터가 멈추는 지점. if not wf: sys.stderr.write("usage: state_engine.py next --workflow WF\n") return 1 print(json.dumps(next_info(wf), ensure_ascii=False, indent=2)) return 0 if cmd == "current": if not wf: sys.stderr.write("usage: state_engine.py current --workflow WF\n") return 1 print(current_stage(wf)) return 0 if cmd == "resolve-family": family = _argval(args, "--family") raw_signals = _argval(args, "--signals", "") signals = [value for value in str(raw_signals).split(",") if value.strip()] resolved = resolve_family(family, signals=signals) if not family or not resolved: sys.stderr.write(f"[state_engine] 등록 family 없음: {family}\n") return 2 print(json.dumps(resolved, ensure_ascii=False, indent=2)) return 0 if cmd == "allowed": if not wf: sys.stderr.write("usage: state_engine.py allowed --workflow WF\n") return 1 for s in allowed_next(wf): print(s) return 0 if cmd == "check": if not wf or not to: sys.stderr.write("usage: state_engine.py check --workflow WF --to STAGE [--actor A]\n") return 1 ok, reasons = can_transition(wf, to, actor=_argval(args, "--actor")) print("ALLOW" if ok else "BLOCK") for r in reasons: print(f" - {r}") return 0 if ok else 1 if cmd == "record": sys.stderr.write( "[state_engine] record 제거됨: caller-supplied --design-type/--report-id/" "--option-count/--evidence-grade는 신뢰할 수 없다. submit-report --report PATH --actor ROLE을 사용하라.\n") return 2 if cmd in ("submit-report", "submit-artifact"): report = _argval(args, "--report") actor = _argval(args, "--actor") if not wf or not report or not actor: sys.stderr.write( f"usage: state_engine.py {cmd} --workflow WF --report PATH --actor ROLE\n" ) return 2 ok, result = submit_report(wf, report, actor) if not ok: sys.stderr.write(f"[state_engine] {cmd} 거부: {result}\n") return 2 print(json.dumps(result, ensure_ascii=False)) return 0 if cmd == "review-artifact": report = _argval(args, "--report") reviewer = _argval(args, "--reviewer") decision = _argval(args, "--decision") if not wf or not report or not reviewer or not decision: sys.stderr.write("usage: state_engine.py review-artifact --workflow WF --report PATH " "--decision accepted|changes-requested|blocked --reviewer ROLE [--supersedes ID]\n") return 2 ok, result = review_artifact(wf, report, decision, reviewer, _argval(args, "--supersedes")) if not ok: sys.stderr.write(f"[state_engine] review-artifact 거부: {result}\n") return 2 print(result["acceptance-event-id"]) return 0 if cmd == "record-quality-gate": review = _argval(args, "--review") actor = _argval(args, "--actor") if not wf or not review or not actor: sys.stderr.write("usage: state_engine.py record-quality-gate --workflow WF --review PATH --actor ROLE\n") return 2 ok, result = record_quality_gate(wf, review, actor) if not ok: sys.stderr.write(f"[state_engine] record-quality-gate 거부: {result}\n") return 2 print(result["workflow-event-id"]) return 0 if cmd == "record-release-decision": report = _argval(args, "--report") actor = _argval(args, "--actor") if not wf or not report or not actor: sys.stderr.write("usage: state_engine.py record-release-decision --workflow WF --report PATH --actor ROLE\n") return 2 ok, result = record_release_decision(wf, report, actor) if not ok: sys.stderr.write(f"[state_engine] record-release-decision 거부: {result}\n") return 2 print(result["workflow-event-id"]) return 0 if cmd in ("block", "block-workflow"): report = _argval(args, "--report") actor = _argval(args, "--actor", "OPS-ORCH") if not wf or not report: sys.stderr.write("usage: state_engine.py block --workflow WF --report PATH [--actor OPS-ORCH]\n") return 2 ok, result = block_workflow(wf, report, actor) if not ok: sys.stderr.write(f"[state_engine] block 거부: {result}\n") return 2 print(f"{wf}: -> blocked") return 0 if cmd in ("resume", "resume-workflow"): evidence = _argval(args, "--evidence") actor = _argval(args, "--actor", "OPS-ORCH") if not wf or not evidence: sys.stderr.write("usage: state_engine.py resume --workflow WF --evidence PATH [--actor OPS-ORCH]\n") return 2 ok, result = resume_workflow(wf, evidence, actor) if not ok: sys.stderr.write(f"[state_engine] resume 거부: {result}\n") return 2 print(f"{wf}: resumed") return 0 if cmd in ("signoff", "record-human-signoff"): # 사람 승인(human-gate). **사람이 세션 밖에서** 호출해야 한다 — guard_tools 가 에이전트의 # 이 CLI 호출을 차단한다(P0-4 soft-boundary). --by 는 HUMAN-. if not wf: sys.stderr.write("usage: state_engine.py signoff --workflow WF --stage STAGE --by HUMAN-\n") return 1 ok, err = record_signoff(wf, _argval(args, "--stage"), _argval(args, "--by")) if not ok: sys.stderr.write(f"[state_engine] signoff 거부: {err}\n") return 1 print(f"{wf}: human-signoff recorded (stage={_argval(args, '--stage')})") return 0 if cmd in ("transition", "enter-stage"): if not wf or not to: sys.stderr.write( f"usage: state_engine.py {cmd} --workflow WF --to STAGE " "[--actor A] [--evidence E]\n" ) return 1 advance = enter_stage if cmd == "enter-stage" else transition ok, reasons = advance( wf, to, evidence=_argval(args, "--evidence"), actor=_argval(args, "--actor") ) if ok: print(f"{wf}: -> {to}") return 0 sys.stderr.write(f"[state_engine] 전이 거부 -> {to}:\n") for r in reasons: sys.stderr.write(f" - {r}\n") return 1 if cmd == "complete-stage": actor = _argval(args, "--actor") if not wf or not actor: sys.stderr.write( "usage: state_engine.py complete-stage --workflow WF --actor OPS-ORCH " "[--to NEXT-STAGE] [--evidence PATH]\n" ) return 2 ok, reasons = complete_stage( wf, actor, evidence=_argval(args, "--evidence"), to=_argval(args, "--to") ) if ok: print(f"{wf}: stage completed ({current_stage(wf)})") return 0 sys.stderr.write("[state_engine] complete-stage 거부:\n") for reason in reasons: sys.stderr.write(f" - {reason}\n") return 2 if cmd in ("init", "init-workflow"): if not wf: sys.stderr.write( "usage: state_engine.py init --workflow WF [--plan P] [--tier T] [--mode M] " "[--parent-workflow WF] [--product-decision ID] [--direction-input-brief PATH]\n" ) return 1 try: led = init_ledger( wf, plan=_argval(args, "--plan", DEFAULT_PLAN), tier=_argval(args, "--tier", DEFAULT_TIER), mode=_argval(args, "--mode", DEFAULT_MODE), overwrite="--overwrite" in args, parent_workflow=_argval(args, "--parent-workflow"), product_decision=_argval(args, "--product-decision"), direction_input_brief=_argval(args, "--direction-input-brief"), ) except ValueError as e: sys.stderr.write(f"[state_engine] init 거부: {e}\n") return 1 p = _ledger_path(wf, create=False) print(os.path.relpath(p, ROOT) if p else f"{wf}: stage={led.get('stage')}") return 0 if cmd == "register-direction-approval": # trusted CLI(P2/E — Task 11): 전 검증 통과 후에만 부모 원장에 design-direction-approval # 을 기록한다. ValueError -> non-zero exit(init 의 exit-1 관례와 동일). try: register_direction_approval( _argval(args, "--parent-workflow"), _argval(args, "--child-workflow"), _argval(args, "--report"), _argval(args, "--report-sha256"), ) except Exception as e: sys.stderr.write(f"[state_engine] register-direction-approval 거부: {e}\n") return 1 print("registered") return 0 if cmd == "register-experience-foundation": try: register_experience_foundation( _argval(args, "--parent-workflow"), _argval(args, "--child-workflow"), ) except Exception as e: sys.stderr.write(f"[state_engine] register-experience-foundation 거부: {e}\n") return 1 print("registered") return 0 if cmd == "find-child-experience": parent = _argval(args, "--parent-workflow") pd = _argval(args, "--product-decision") if not (parent and pd): sys.stderr.write( "usage: state_engine.py find-child-experience --parent-workflow WF --product-decision ID\n") return 1 found = find_child_experience_workflow(parent, pd) print(json.dumps(found, ensure_ascii=False) if found else "null") return 0 if cmd == "check-experience-foundation": if not wf: sys.stderr.write("usage: state_engine.py check-experience-foundation --workflow WF\n") return 1 led = _load_ledger_safe(wf) ok = ((not _experience_foundation_required(wf)) or _has_experience_foundation(wf, led)) print(f"{wf}: experience-foundation={'YES' if ok else 'NO'}") return 0 if ok else 3 if cmd == "find-child-direction": # dedup CLI(Task 13 — closes the gap that find_child_direction_workflow(Task 10) had no # CLI, so /design couldn't call it without an inline python -c). --parent-workflow + # --product-decision 필수, --direction-input-brief-sha256 은 staleness 판정용(생략 가능 # — 그러면 stale 은 항상 True 로 보수적으로 보고된다, find_child_direction_workflow 참조). parent = _argval(args, "--parent-workflow") pd = _argval(args, "--product-decision") brief_sha = _argval(args, "--direction-input-brief-sha256") if not (parent and pd): sys.stderr.write( "usage: state_engine.py find-child-direction --parent-workflow WF --product-decision ID " "[--direction-input-brief-sha256 SHA]\n") return 1 found = find_child_direction_workflow(parent, pd, brief_sha) print(json.dumps(found, ensure_ascii=False) if found else "null") return 0 if cmd == "check-direction-approved": if not wf: sys.stderr.write("usage: state_engine.py check-direction-approved --workflow WF\n") return 1 led = _load_ledger_safe(wf) ok = _has_direction_approval(wf, led) print(f"{wf}: direction-approved={'YES' if ok else 'NO'}") return 0 if ok else 3 if cmd == "set-tier": # F1: 기존 워크플로 tier 승격(운영 fact, PROTECTED 아님). init --overwrite 는 이력 파괴, # 원장 직접편집은 guard_tools 차단 → 이 CLI 가 유일한 지원 경로. 다운그레이드는 금지한다 # (heavy->light 로 human-gate 등 상위 게이트를 우회하는 것을 막는다). _order = {"light": 0, "standard": 1, "heavy": 2} new_tier = _argval(args, "--tier") if not wf or new_tier not in _order: sys.stderr.write("usage: state_engine.py set-tier --workflow WF --tier light|standard|heavy\n") return 1 led = _load_ledger_safe(wf) if not led: sys.stderr.write(f"[state_engine] 원장 없음: {wf}\n") return 1 old = led.get("tier") or DEFAULT_TIER if _order[new_tier] < _order.get(old, 1): sys.stderr.write(f"[state_engine] tier 다운그레이드 거부({old}->{new_tier}) — 상위 게이트 우회 방지. " "낮은 tier 가 필요하면 새 워크플로로 시작하라.\n") return 2 event = { "workflow-event-id": f"wfe-{_stamp()}-{uuid.uuid4().hex[:8]}", "event-type": "tier-escalated", "workflow-id": wf, "from-tier": old, "to-tier": new_tier, "actor": "OPS-ORCH", "effective-at": _now(), } committed, error = _atomic_event_transaction(wf, workflow_event=event) if not committed: sys.stderr.write(f"[state_engine] tier event 기록 실패: {error}\n") return 2 print(f"{wf}: tier {old} -> {new_tier}") return 0 if cmd == "progress": if not wf: sys.stderr.write("usage: state_engine.py progress --workflow WF " "[--round N] [--stall N] [--next FAM] [--progressing true|false] " "[--satisfied true|false] [--set key=value ...]\n") return 1 updates = {} # 공통 wave 필드(Magentic 원장) — 지정된 것만 갱신. rnd = _argval(args, "--round") if rnd is not None: updates["round"] = _coerce(rnd) stall = _argval(args, "--stall") if stall is not None: updates["stall_count"] = _coerce(stall) nxt = _argval(args, "--next") if nxt is not None: updates["next"] = nxt prog = _argval(args, "--progressing") if prog is not None: updates["is_progress_being_made"] = _coerce(prog) sat = _argval(args, "--satisfied") if sat is not None: updates["is_request_satisfied"] = _coerce(sat) # 자유형 --set key=value (여러 번 가능) for i, a in enumerate(args): if a == "--set" and i + 1 < len(args) and "=" in args[i + 1]: k, v = args[i + 1].split("=", 1) updates[k.strip()] = _coerce(v.strip()) ok, result = update_progress(wf, updates, plan=_argval(args, "--plan")) if not ok: sys.stderr.write("[state_engine] progress 기록 실패(degrade) — workspace 설정 확인\n") print(json.dumps(result, ensure_ascii=False, sort_keys=True)) return 0 if ok else 1 sys.stderr.write(__doc__) return 1 if __name__ == "__main__": try: sys.exit(main()) except Exception as e: # 최종 안전망 — 어떤 경우에도 크래시로 파이프라인을 막지 않는다 _log(f"unexpected: {e}") sys.exit(0)