init: company-haness 설계
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Org OS runtime kernel modules."""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Deterministic intake, coverage, role, budget and task-graph planning."""
|
||||
|
||||
from .role_selector import select_minimum_sufficient_roles
|
||||
|
||||
__all__ = ["select_minimum_sufficient_roles"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Token estimates and tier limits used by the executable role planner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
)
|
||||
KPI_PATH = os.path.join(ROOT, "org-os", "06-agent-work", "agent-operating-kpi.yaml")
|
||||
|
||||
TIER_ROLE_LIMITS = {"light": 2, "standard": 5, "heavy": 12}
|
||||
TIER_ROLE_TOKENS = {
|
||||
"light": {"input": 9000, "output": 2500},
|
||||
"standard": {"input": 18000, "output": 4500},
|
||||
"heavy": {"input": 32000, "output": 8000},
|
||||
}
|
||||
ROLE_TYPE_MULTIPLIER = {
|
||||
"coordinator": 0.65,
|
||||
"worker": 1.0,
|
||||
"recommender": 0.9,
|
||||
"reviewer": 0.85,
|
||||
"auditor": 0.9,
|
||||
"decider": 1.05,
|
||||
}
|
||||
|
||||
|
||||
def workflow_budget(tier: str, override: int | None = None) -> int:
|
||||
if override is not None:
|
||||
return max(0, int(override))
|
||||
try:
|
||||
data = yaml.safe_load(open(KPI_PATH, encoding="utf-8")) or {}
|
||||
budgets = data["agent-operating-kpi"]["token-budgets"]["per-wave"]
|
||||
return int(budgets[tier])
|
||||
except Exception:
|
||||
return {"light": 150000, "standard": 500000, "heavy": 2000000}.get(tier, 500000)
|
||||
|
||||
|
||||
def estimate_role(role: dict[str, Any], tier: str, stage: str | None = None) -> dict[str, int]:
|
||||
base = TIER_ROLE_TOKENS.get(tier, TIER_ROLE_TOKENS["standard"])
|
||||
multiplier = ROLE_TYPE_MULTIPLIER.get(str(role.get("role-type") or "worker"), 1.0)
|
||||
if stage in {"verification", "acceptance"}:
|
||||
multiplier *= 0.8
|
||||
input_tokens = int(base["input"] * multiplier)
|
||||
output_tokens = int(base["output"] * multiplier)
|
||||
return {"input": input_tokens, "output": output_tokens, "total": input_tokens + output_tokens}
|
||||
|
||||
|
||||
def estimate_plan(selected_roles: list[dict[str, Any]], tier: str, stage: str | None = None) -> dict[str, int]:
|
||||
input_tokens = output_tokens = 0
|
||||
for role in selected_roles:
|
||||
estimate = estimate_role(role, tier, stage)
|
||||
input_tokens += estimate["input"]
|
||||
output_tokens += estimate["output"]
|
||||
synthesis = 0 if len(selected_roles) <= 1 else (3500 if tier == "light" else 7000) * (len(selected_roles) - 1)
|
||||
return {
|
||||
"input": input_tokens,
|
||||
"output": output_tokens,
|
||||
"synthesis": synthesis,
|
||||
"total": input_tokens + output_tokens + synthesis,
|
||||
}
|
||||
|
||||
|
||||
def max_selected_roles(tier: str) -> int:
|
||||
return TIER_ROLE_LIMITS.get(tier, TIER_ROLE_LIMITS["standard"])
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Coverage vocabulary derived from role, family, artifact and workflow contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RISK_ROLE_HINTS = {
|
||||
"security": {"SEC-ENGINEER", "SEC-APPSEC", "SEC-CHAMPION", "SEC-DEVSECOPS"},
|
||||
"privacy": {"SEC-APPSEC", "GTM-LEGAL"},
|
||||
"legal": {"GTM-LEGAL"},
|
||||
"reliability": {"SRE", "INFRA-PLATFORM", "EXEC-VPENG"},
|
||||
"quality": {"QA", "EXEC-VPENG"},
|
||||
"financial": {"EXEC-CFO", "GTM-PRICING", "CONSULT-FIN"},
|
||||
"user-harm": {"QA", "EXEC-CPO", "SEC-APPSEC"},
|
||||
}
|
||||
|
||||
# Workload-profile.required-capabilities is an executable coverage contract.
|
||||
# Keep the mapping concrete: a family label by itself must not satisfy a role
|
||||
# specific need such as competitive intelligence.
|
||||
CAPABILITY_ROLE_HINTS = {
|
||||
"product": {"EXEC-CPO", "PROD-PM", "PROD-PO", "PROD-TPO", "PROD-PPO"},
|
||||
"product-delivery": {"PROD-PM", "PROD-PO", "PROD-TPO", "PROD-PPO", "EXEC-VPENG"},
|
||||
"customer-research": {"UX-RESEARCHER", "DATA-ANALYST"},
|
||||
"competitive-intelligence": {"GTM-CI"},
|
||||
"revenue": {"GTM-REVOPS", "GTM-PRICING", "GTM-SALES", "GTM-GROWTHPM"},
|
||||
"gtm": {"GTM-CI", "GTM-PMM", "GTM-DEMANDGEN", "GTM-SALES", "GTM-REVOPS"},
|
||||
"finance": {"EXEC-CFO", "CONSULT-FIN", "GTM-PRICING"},
|
||||
"strategy": {"STR-ANALYST", "CONSULT-STRAT"},
|
||||
"operations": {"EXEC-COO", "OPS-CH", "OPS-CREW", "CONSULT-OPS"},
|
||||
"design": {"DES-DIRECTOR", "DES-PROD", "DES-PLATFORM", "DES-INTERNAL", "DES-VISUAL"},
|
||||
"information-architecture": {"DOC-IA"},
|
||||
"technical": {"EXEC-CTO", "EXEC-CPTO", "ARCH-TECH", "ARCH-SOLUTION"},
|
||||
"architecture": {"ARCH-EA", "ARCH-SOLUTION", "ARCH-APP", "ARCH-TECH", "ARCH-SWAT"},
|
||||
"engineering": {"EXEC-VPENG", "ENG-FE", "ENG-BE", "ENG-SW"},
|
||||
"frontend": {"ENG-FE", "ENG-FEPLAT", "ENG-FEUX"},
|
||||
"backend": {"ENG-BE", "ENG-BEGEN", "ENG-PRODSERVER", "ENG-PLATSERVER", "ENG-SW"},
|
||||
"public-api": {"ARCH-APP", "ARCH-TECH", "ENG-BE", "ENG-PRODSERVER"},
|
||||
"persistence": {"ARCH-DATA", "DATA-ENGINEER", "ENG-BE"},
|
||||
"platform": {"INFRA-PLATFORM", "ENG-FEPLAT", "ENG-PLATSERVER", "PROD-PPO"},
|
||||
"data": {"ARCH-DATA", "DATA-ENGINEER", "DATA-BIGDATA", "DATA-ANALYST"},
|
||||
"security": {"SEC-ENGINEER", "SEC-APPSEC", "SEC-CHAMPION", "SEC-DEVSECOPS"},
|
||||
"privacy": {"SEC-APPSEC", "GTM-LEGAL"},
|
||||
"legal": {"GTM-LEGAL"},
|
||||
"quality": {"QA", "EXEC-VPENG"},
|
||||
"kpi-test": {"DATA-ANALYST", "QA"},
|
||||
"infrastructure": {"INFRA-DEV", "INFRA-PLATFORM", "INFRA-DEVOPS", "SRE"},
|
||||
"documentation": {"DOC-LEAD", "DOC-WRITER", "DOC-IA", "DOC-VISUAL", "DOC-EDU"},
|
||||
}
|
||||
|
||||
|
||||
def tokens(value: Any) -> set[str]:
|
||||
if value is None:
|
||||
return set()
|
||||
if isinstance(value, dict):
|
||||
value = " ".join(f"{key} {item}" for key, item in value.items())
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
value = " ".join(str(item) for item in value)
|
||||
return {part for part in re.split(r"[^\w]+", str(value).lower(), flags=re.UNICODE)
|
||||
if len(part) > 1 and part != "_"}
|
||||
|
||||
|
||||
def artifact_maps(artifact_registry: dict[str, Any]) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
|
||||
producer: dict[str, set[str]] = {}
|
||||
reviewer: dict[str, set[str]] = {}
|
||||
for kind, definition in (artifact_registry.get("artifact-kinds") or {}).items():
|
||||
for role in definition.get("producer-roles", []) or []:
|
||||
producer.setdefault(str(role), set()).add(str(kind))
|
||||
capability = definition.get("reviewer-capability")
|
||||
if capability:
|
||||
reviewer.setdefault(str(capability), set()).add(str(kind))
|
||||
return producer, reviewer
|
||||
|
||||
|
||||
def role_coverage(
|
||||
role: dict[str, Any],
|
||||
family: dict[str, Any],
|
||||
profile: dict[str, Any] | None,
|
||||
artifact_registry: dict[str, Any],
|
||||
role_capabilities: dict[str, list[str]],
|
||||
) -> set[str]:
|
||||
role_id = str(role.get("role-id"))
|
||||
family_id = str(family.get("family-id"))
|
||||
producer, reviewer_kinds = artifact_maps(artifact_registry)
|
||||
coverage = {"owner", f"family:{family_id}", f"owner:{family_id}", f"role:{role_id}"}
|
||||
coverage |= {f"lens:{lens}" for lens in family.get("carries-lenses", []) or []}
|
||||
if family.get("audit-capable"):
|
||||
coverage.add("lens:LENS-CONTRARIAN")
|
||||
coverage |= {f"artifact:{kind}" for kind in producer.get(role_id, set())}
|
||||
for capability, roles in role_capabilities.items():
|
||||
if role_id in set(roles or []):
|
||||
coverage.add(f"capability:{capability}")
|
||||
coverage |= {f"review:{kind}" for kind in reviewer_kinds.get(capability, set())}
|
||||
for capability, role_ids in CAPABILITY_ROLE_HINTS.items():
|
||||
if role_id in role_ids:
|
||||
coverage.add(f"capability:{capability}")
|
||||
if role.get("is-decision-maker"):
|
||||
coverage.add("authority")
|
||||
if role.get("role-type") in {"auditor", "reviewer"} or family.get("audit-capable"):
|
||||
coverage.add("independent-review")
|
||||
if role.get("is-execution-agent"):
|
||||
coverage.add("implementation")
|
||||
for risk, role_ids in RISK_ROLE_HINTS.items():
|
||||
if role_id in role_ids:
|
||||
coverage.add(f"risk:{risk}")
|
||||
searchable = " ".join([
|
||||
role_id,
|
||||
str(role.get("role-name") or ""),
|
||||
str((profile or {}).get("perspective") or ""),
|
||||
str((profile or {}).get("scope") or ""),
|
||||
" ".join((profile or {}).get("responsibilities", []) or []),
|
||||
])
|
||||
coverage |= {f"keyword:{token}" for token in tokens(searchable)}
|
||||
return coverage
|
||||
|
||||
|
||||
def required_coverage(profile: dict[str, Any], candidate_family_ids: list[str]) -> set[str]:
|
||||
required = {str(item) for item in profile.get("required-coverage", []) or []}
|
||||
required_families = profile.get("required-families", []) or []
|
||||
if isinstance(required_families, str):
|
||||
required_families = [required_families]
|
||||
required |= {f"owner:{family_id}" for family_id in required_families}
|
||||
if candidate_family_ids:
|
||||
required.add("owner")
|
||||
required |= {f"artifact:{kind}" for kind in profile.get("required-artifacts", []) or []}
|
||||
capabilities = profile.get("required-capabilities", []) or []
|
||||
if isinstance(capabilities, str):
|
||||
capabilities = [capabilities]
|
||||
required |= {f"capability:{str(capability).strip().lower()}"
|
||||
for capability in capabilities if str(capability or "").strip()}
|
||||
risks = profile.get("risks", []) or []
|
||||
if isinstance(risks, dict):
|
||||
risks = [key for key, value in risks.items() if value]
|
||||
required |= {f"risk:{str(risk).lower()}" for risk in risks}
|
||||
if profile.get("authority-required"):
|
||||
required.add("authority")
|
||||
if profile.get("implementation-required") or profile.get("workflow-stage") in {"build", "run"}:
|
||||
required.add("implementation")
|
||||
if profile.get("independent-review-required"):
|
||||
required.add("independent-review")
|
||||
return required
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Deterministic request classifier: light operational, substantial, or strategic."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .coverage_model import tokens
|
||||
|
||||
STRATEGIC = {
|
||||
"strategy", "portfolio", "pricing", "budget", "roadmap", "acquisition", "partnership",
|
||||
"compliance", "legal", "production", "customer", "revenue", "one-way", "irreversible",
|
||||
"전략", "포트폴리오", "가격", "예산", "로드맵", "인수", "법무", "규제", "매출", "고객",
|
||||
}
|
||||
LIGHT = {
|
||||
"typo", "spelling", "docs", "comment", "rename", "format", "config", "test", "small",
|
||||
"오탈자", "문서", "주석", "이름", "포맷", "설정", "테스트", "작은",
|
||||
}
|
||||
SUBSTANTIAL = {
|
||||
"feature", "refactor", "migration", "architecture", "api", "database", "security", "design",
|
||||
"기능", "리팩터", "마이그레이션", "아키텍처", "데이터베이스", "보안", "설계",
|
||||
}
|
||||
|
||||
|
||||
def classify_request(request: str, facts: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
facts = facts or {}
|
||||
observed = tokens(request) | tokens(facts)
|
||||
searchable = (request + " " + str(facts)).lower()
|
||||
strategic_hits = sorted({term for term in STRATEGIC if term in observed or term in searchable})
|
||||
substantial_hits = sorted({term for term in SUBSTANTIAL if term in observed or term in searchable})
|
||||
light_hits = sorted({term for term in LIGHT if term in observed or term in searchable})
|
||||
if facts.get("one-way-door") or facts.get("blast-radius") == "production-customer-revenue" or strategic_hits:
|
||||
route = "strategic"
|
||||
plan = "cascade"
|
||||
executive = True
|
||||
elif substantial_hits or facts.get("cross-team"):
|
||||
route = "substantial"
|
||||
plan = "cascade"
|
||||
executive = False
|
||||
else:
|
||||
route = "light-operational"
|
||||
plan = "light"
|
||||
executive = False
|
||||
return {
|
||||
"classification": route,
|
||||
"plan": plan,
|
||||
"executive-required": executive,
|
||||
"signals": {
|
||||
"strategic": strategic_hits,
|
||||
"substantial": substantial_hits,
|
||||
"light": light_hits,
|
||||
},
|
||||
"reason": (
|
||||
"decision authority or high-blast signal" if executive else
|
||||
"substantial implementation/design signal" if route == "substantial" else
|
||||
"reversible owner-scoped task"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Shared divergent-lens policy derived from the role registries.
|
||||
|
||||
The policy deliberately reasons about registered family/role capabilities. A
|
||||
caller supplied lens label is never sufficient evidence that a role can carry
|
||||
that lens.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
)
|
||||
REG = os.path.join(ROOT, "org-os", "00-role-registry")
|
||||
TIERS = os.path.join(ROOT, "org-os", "06-agent-work", "governance-tiers.yaml")
|
||||
|
||||
|
||||
def _load(path: str) -> dict[str, Any]:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def registries() -> tuple[dict[str, Any], dict[str, Any], list[str]]:
|
||||
families_doc = _load(os.path.join(REG, "capability-families.yaml"))
|
||||
lenses_doc = _load(os.path.join(REG, "lens-registry.yaml"))
|
||||
families = {
|
||||
str(item.get("family-id")): item
|
||||
for item in (families_doc.get("capability-families", {}) or {}).get("families", []) or []
|
||||
if isinstance(item, dict) and item.get("family-id")
|
||||
}
|
||||
lens_items = (lenses_doc.get("lens-registry", {}) or {}).get("lenses", []) or []
|
||||
lenses = {
|
||||
str(item.get("lens-id")): item
|
||||
for item in lens_items if isinstance(item, dict) and item.get("lens-id")
|
||||
}
|
||||
order = [str(item.get("lens-id")) for item in lens_items
|
||||
if isinstance(item, dict) and item.get("lens-id")]
|
||||
return families, lenses, order
|
||||
|
||||
|
||||
def normalize_family_ids(values: Any) -> list[str]:
|
||||
if isinstance(values, str):
|
||||
values = [values]
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
return [str(value).strip().upper() for value in values if str(value or "").strip()]
|
||||
|
||||
|
||||
def candidate_family_errors(values: Any, *, tier: str, mode: str,
|
||||
enforce_lens_floor: bool = False) -> list[str]:
|
||||
family_ids = normalize_family_ids(values)
|
||||
families, _lenses, _order = registries()
|
||||
errors: list[str] = []
|
||||
if not family_ids:
|
||||
return ["candidate-families는 비어 있지 않은 등록 family 목록이어야 한다"]
|
||||
duplicates = sorted({value for value in family_ids if family_ids.count(value) > 1})
|
||||
unknown = sorted(set(family_ids) - set(families))
|
||||
if duplicates:
|
||||
errors.append(f"candidate-families 중복: {duplicates}")
|
||||
if unknown:
|
||||
errors.append(f"candidate-families 미등록 family: {unknown}")
|
||||
if errors or (str(mode).lower() != "divergent" and not enforce_lens_floor):
|
||||
return errors
|
||||
|
||||
available = available_lenses(family_ids)
|
||||
policy = divergent_policy(tier)
|
||||
minimum = policy.get("min-distinct-lenses")
|
||||
if isinstance(minimum, int) and len(available) < minimum:
|
||||
errors.append(
|
||||
f"candidate-families 이론 렌즈 커버리지 부족: {len(available)} < tier {tier} 최소 {minimum}"
|
||||
)
|
||||
if policy.get("contrarian-required") and "LENS-CONTRARIAN" not in available:
|
||||
errors.append(
|
||||
"candidate-families에 contrarian rotation을 맡을 audit-capable family가 없다"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def divergent_policy(tier: str) -> dict[str, Any]:
|
||||
doc = _load(TIERS).get("governance-tiers", {}) or {}
|
||||
return (((doc.get("tiers") or {}).get(str(tier).lower()) or {}).get("divergent") or {})
|
||||
|
||||
|
||||
def available_lenses(family_ids: list[str]) -> set[str]:
|
||||
"""Return lenses the candidate set can actually assign.
|
||||
|
||||
Contrarian is special: the registry intentionally has no fixed carrier. It
|
||||
becomes available only when the candidate set contains an audit-capable
|
||||
family that can be rotated in independently.
|
||||
"""
|
||||
families, lenses, _order = registries()
|
||||
selected = {family_id for family_id in family_ids if family_id in families}
|
||||
result: set[str] = set()
|
||||
for lens_id, lens in lenses.items():
|
||||
if set(lens.get("carrier-families", []) or []) & selected:
|
||||
result.add(lens_id)
|
||||
for family_id in selected:
|
||||
result.update(families[family_id].get("carries-lenses", []) or [])
|
||||
if any(families[family_id].get("audit-capable") for family_id in selected):
|
||||
result.add("LENS-CONTRARIAN")
|
||||
return result
|
||||
|
||||
|
||||
def required_lenses(family_ids: list[str], *, tier: str, mode: str) -> set[str]:
|
||||
if str(mode).lower() != "divergent":
|
||||
return set()
|
||||
available = available_lenses(family_ids)
|
||||
policy = divergent_policy(tier)
|
||||
minimum = policy.get("min-distinct-lenses")
|
||||
contrarian = bool(policy.get("contrarian-required"))
|
||||
if minimum == "all-relevant":
|
||||
return available
|
||||
count = int(minimum or 0)
|
||||
required: list[str] = []
|
||||
if contrarian and "LENS-CONTRARIAN" in available:
|
||||
required.append("LENS-CONTRARIAN")
|
||||
_families, _lenses, order = registries()
|
||||
required.extend(lens for lens in order if lens in available and lens not in required)
|
||||
return set(required[:count])
|
||||
|
||||
|
||||
def family_for_role(role_id: str) -> tuple[str | None, dict[str, Any] | None]:
|
||||
families, _lenses, _order = registries()
|
||||
wanted = str(role_id or "").upper()
|
||||
for family_id, family in families.items():
|
||||
if wanted in {str(value).upper() for value in family.get("member-role-ids", []) or []}:
|
||||
return family_id, family
|
||||
return None, None
|
||||
|
||||
|
||||
def role_can_carry_lens(role_id: str, lens_id: str) -> bool:
|
||||
family_id, family = family_for_role(role_id)
|
||||
if not family_id or not family:
|
||||
return False
|
||||
lens_id = str(lens_id or "").upper()
|
||||
if lens_id == "LENS-CONTRARIAN":
|
||||
return bool(family.get("audit-capable"))
|
||||
return lens_id in available_lenses([family_id])
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Minimum-sufficient concrete-role planner.
|
||||
|
||||
Families are candidate pools, never actors. The planner uses a deterministic greedy
|
||||
set-cover with token and independence constraints and records both selected and skipped roles.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .budget_planner import estimate_plan, estimate_role, max_selected_roles, workflow_budget
|
||||
from .coverage_model import (
|
||||
CAPABILITY_ROLE_HINTS,
|
||||
RISK_ROLE_HINTS,
|
||||
required_coverage,
|
||||
role_coverage,
|
||||
tokens,
|
||||
)
|
||||
from .lens_policy import candidate_family_errors, normalize_family_ids, required_lenses
|
||||
from .task_graph import build_task_graph
|
||||
|
||||
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
)
|
||||
REG = os.path.join(ROOT, "org-os", "00-role-registry")
|
||||
ARTIFACT_REGISTRY = os.path.join(ROOT, "org-os", "06-agent-work", "generated", "artifact-registry.yaml")
|
||||
CONTRACTS = os.path.join(ROOT, "org-os", "06-agent-work", "workflow-contracts.yaml")
|
||||
SCORECARD = os.path.join(REG, "role-selection-scorecard.yaml")
|
||||
EXECUTION_POLICY = os.path.join(ROOT, "org-os", "06-agent-work", "execution-policy.yaml")
|
||||
|
||||
|
||||
def _load(path: str) -> dict[str, Any]:
|
||||
try:
|
||||
return yaml.safe_load(open(path, encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _registries() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], dict[str, list[str]]]:
|
||||
roles_doc = _load(os.path.join(REG, "roles.yaml")).get("role-registry", {}) or {}
|
||||
families_doc = _load(os.path.join(REG, "capability-families.yaml")).get("capability-families", {}) or {}
|
||||
profiles_doc = _load(os.path.join(REG, "role-profiles.yaml")).get("role-profiles", {}) or {}
|
||||
artifacts = _load(ARTIFACT_REGISTRY).get("artifact-registry", {}) or {}
|
||||
contracts = _load(CONTRACTS).get("workflow-contracts", {}) or {}
|
||||
roles = {str(item["role-id"]): item for item in roles_doc.get("roles", []) or [] if item.get("role-id")}
|
||||
families = {str(item["family-id"]): item for item in families_doc.get("families", []) or [] if item.get("family-id")}
|
||||
profiles = {str(item["role-id"]): item for item in profiles_doc.get("profiles", []) or [] if item.get("role-id")}
|
||||
return roles, families, profiles, artifacts, contracts.get("role-capabilities", {}) or {}
|
||||
|
||||
|
||||
def _candidate_families(profile: dict[str, Any], families: dict[str, Any]) -> list[str]:
|
||||
has_explicit = "candidate-families" in profile or "candidate-family" in profile
|
||||
explicit = profile.get("candidate-families") if "candidate-families" in profile else profile.get("candidate-family")
|
||||
if has_explicit:
|
||||
# Unknown ids are intentionally retained here and rejected by the caller;
|
||||
# silently dropping them used to turn a malformed explicit plan into an
|
||||
# unrelated inferred plan.
|
||||
return normalize_family_ids(explicit)
|
||||
signal_tokens = tokens(profile.get("signals")) | tokens(profile.get("objective"))
|
||||
ranked = []
|
||||
for family_id, family in families.items():
|
||||
haystack = tokens(family.get("invocation-triggers")) | tokens(family_id)
|
||||
overlap = len(signal_tokens & haystack)
|
||||
if overlap:
|
||||
ranked.append((-overlap, family_id))
|
||||
return [family_id for _, family_id in sorted(ranked)[:5]]
|
||||
|
||||
|
||||
def _score(role: dict[str, Any], family: dict[str, Any], coverage: set[str], required: set[str], profile: dict[str, Any]) -> dict[str, int]:
|
||||
signal_tokens = tokens(profile.get("signals")) | tokens(profile.get("objective"))
|
||||
keyword_hits = len({value.split(":", 1)[1] for value in coverage if value.startswith("keyword:")} & signal_tokens)
|
||||
if ("owner" in required
|
||||
and family.get("lead-role-id") == role.get("role-id")):
|
||||
relevance = 3
|
||||
elif f"owner:{family['family-id']}" in required:
|
||||
relevance = 3
|
||||
elif "owner" in required:
|
||||
relevance = min(3, 1 + keyword_hits)
|
||||
else:
|
||||
relevance = min(3, keyword_hits)
|
||||
risk_coverage = min(3, len({value for value in required & coverage if value.startswith("risk:")}))
|
||||
evidence_need = 3 if any(value.startswith("artifact:") for value in required & coverage) else 0
|
||||
decision_authority = 3 if "authority" in required & coverage else 0
|
||||
implementation_impact = 3 if "implementation" in required & coverage else (1 if role.get("is-execution-agent") else 0)
|
||||
evidence = profile.get("already-available-evidence", []) or profile.get("existing-evidence", []) or []
|
||||
duplicate_penalty = 3 if f"role:{role['role-id']}" in set(evidence) else 0
|
||||
total = relevance + risk_coverage + evidence_need + decision_authority + implementation_impact - duplicate_penalty
|
||||
return {
|
||||
"relevance": relevance,
|
||||
"risk-coverage": risk_coverage,
|
||||
"evidence-need": evidence_need,
|
||||
"decision-authority": decision_authority,
|
||||
"implementation-impact": implementation_impact,
|
||||
"duplicate-penalty": duplicate_penalty,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def select_minimum_sufficient_roles(profile: dict[str, Any]) -> dict[str, Any]:
|
||||
roles, families, profiles, artifacts, role_capabilities = _registries()
|
||||
tier = str(profile.get("tier") or "standard").lower()
|
||||
stage = str(profile.get("workflow-stage") or profile.get("stage") or "") or None
|
||||
scorecard = _load(SCORECARD).get("role-selection-scorecard", {}) or {}
|
||||
execution_policy = _load(EXECUTION_POLICY).get("execution-policy", {}) or {}
|
||||
family_ids = _candidate_families(profile, families)
|
||||
has_explicit = "candidate-families" in profile or "candidate-family" in profile
|
||||
family_errors = (candidate_family_errors(
|
||||
family_ids, tier=tier, mode=str(profile.get("mode") or "converge"))
|
||||
if has_explicit else [])
|
||||
if family_errors:
|
||||
empty_estimate = estimate_plan([], tier, stage)
|
||||
plan = {
|
||||
"version": 1,
|
||||
"workflow-id": profile.get("workflow-id"),
|
||||
"tier": tier,
|
||||
"workflow-stage": stage,
|
||||
"candidate-families": family_ids,
|
||||
"selected": {"owner": None, "contributors": [], "reviewers": []},
|
||||
"skipped": [],
|
||||
"coverage": {"required": [], "already-covered": [], "covered": [], "missing": []},
|
||||
"estimated-tokens": empty_estimate,
|
||||
"budget": {"max-total": workflow_budget(tier, profile.get("token-budget")),
|
||||
"within-budget": True},
|
||||
"status": "blocked",
|
||||
"errors": family_errors,
|
||||
}
|
||||
plan["task-graph"] = build_task_graph(plan)
|
||||
return {"selection-plan": plan}
|
||||
if profile.get("auto-expand-candidates", True):
|
||||
family_by_role = {role_id: family_id for family_id, family in families.items()
|
||||
for role_id in family.get("member-role-ids", []) or []}
|
||||
artifact_kinds = artifacts.get("artifact-kinds", {}) or {}
|
||||
extra_roles = set()
|
||||
for kind in profile.get("required-artifacts", []) or []:
|
||||
extra_roles |= set((artifact_kinds.get(kind) or {}).get("producer-roles", []) or [])
|
||||
risks = profile.get("risks", []) or []
|
||||
if isinstance(risks, dict):
|
||||
risks = [key for key, value in risks.items() if value]
|
||||
for risk in risks:
|
||||
extra_roles |= RISK_ROLE_HINTS.get(str(risk).lower(), set())
|
||||
capabilities = profile.get("required-capabilities", []) or []
|
||||
if isinstance(capabilities, str):
|
||||
capabilities = [capabilities]
|
||||
for capability in capabilities:
|
||||
extra_roles |= CAPABILITY_ROLE_HINTS.get(str(capability).strip().lower(), set())
|
||||
if profile.get("authority-required"):
|
||||
extra_roles |= {role_id for role_id, role in roles.items() if role.get("is-decision-maker")}
|
||||
for role_id in sorted(extra_roles):
|
||||
family_id = family_by_role.get(role_id)
|
||||
if family_id and family_id not in family_ids:
|
||||
family_ids.append(family_id)
|
||||
required = required_coverage(profile, family_ids)
|
||||
required |= {f"lens:{lens}" for lens in required_lenses(
|
||||
family_ids, tier=tier, mode=str(profile.get("mode") or "converge"))}
|
||||
existing = {str(item) for item in profile.get("already-available-evidence", []) or profile.get("existing-evidence", []) or []}
|
||||
# Evidence may discharge an evidence/artifact need, but it cannot stand in
|
||||
# for assigning a concrete capability or divergent lens carrier.
|
||||
non_delegable = {item for item in required
|
||||
if item.startswith("capability:") or item.startswith("lens:")}
|
||||
effective_existing = existing - non_delegable
|
||||
uncovered = required - effective_existing
|
||||
excluded_roles = {str(value).upper() for value in profile.get("excluded-role-ids", []) or []}
|
||||
candidates = []
|
||||
for family_id in family_ids:
|
||||
family = families[family_id]
|
||||
for role_id in family.get("member-role-ids", []) or []:
|
||||
if str(role_id).upper() in excluded_roles:
|
||||
continue
|
||||
role = roles.get(str(role_id))
|
||||
if not role:
|
||||
continue
|
||||
coverage = role_coverage(role, family, profiles.get(str(role_id)), artifacts, role_capabilities)
|
||||
candidates.append({
|
||||
"role": role,
|
||||
"family": family,
|
||||
"coverage": coverage,
|
||||
"score": _score(role, family, coverage, required, profile),
|
||||
})
|
||||
|
||||
selected: list[dict[str, Any]] = []
|
||||
limit = int(profile.get("max-selected-roles") or max_selected_roles(tier))
|
||||
budget = workflow_budget(tier, profile.get("token-budget"))
|
||||
while uncovered and candidates and len(selected) < limit:
|
||||
ranked = []
|
||||
for candidate in candidates:
|
||||
gain = candidate["coverage"] & uncovered
|
||||
if not gain:
|
||||
continue
|
||||
cost = estimate_role(candidate["role"], tier, stage)["total"]
|
||||
ranked.append((
|
||||
-(len(gain) * 100000 + candidate["score"]["total"] * 1000 - cost),
|
||||
candidate["role"]["role-id"], candidate, gain,
|
||||
))
|
||||
if not ranked:
|
||||
break
|
||||
_, _, chosen, gain = sorted(ranked, key=lambda value: (value[0], value[1]))[0]
|
||||
tentative = selected + [chosen]
|
||||
if estimate_plan([item["role"] for item in tentative], tier, stage)["total"] > budget:
|
||||
break
|
||||
selected.append(chosen)
|
||||
candidates.remove(chosen)
|
||||
uncovered -= gain
|
||||
|
||||
# Independent review is a relational constraint: a producer cannot review its own output.
|
||||
producer_ids = {item["role"]["role-id"] for item in selected
|
||||
if "implementation" in item["coverage"] or any(v.startswith("artifact:") for v in item["coverage"] & required)}
|
||||
needs_review = "independent-review" in required
|
||||
if needs_review and not any(item["role"]["role-id"] not in producer_ids and "independent-review" in item["coverage"] for item in selected):
|
||||
reviewer_pool = []
|
||||
for family_id, family in families.items():
|
||||
if not family.get("audit-capable"):
|
||||
continue
|
||||
for role_id in family.get("member-role-ids", []) or []:
|
||||
if role_id in producer_ids or role_id not in roles:
|
||||
continue
|
||||
coverage = role_coverage(roles[role_id], family, profiles.get(role_id), artifacts, role_capabilities)
|
||||
risk_gain = len(coverage & required)
|
||||
reviewer_pool.append((-risk_gain, role_id, {"role": roles[role_id], "family": family,
|
||||
"coverage": coverage,
|
||||
"score": _score(roles[role_id], family, coverage, required, profile)}))
|
||||
if reviewer_pool and len(selected) < limit:
|
||||
reviewer = sorted(reviewer_pool)[0][2]
|
||||
if estimate_plan([item["role"] for item in selected + [reviewer]], tier, stage)["total"] <= budget:
|
||||
selected.append(reviewer)
|
||||
uncovered.discard("independent-review")
|
||||
|
||||
selected_ids = {item["role"]["role-id"] for item in selected}
|
||||
owner_items = [item for item in selected if "owner" in item["coverage"] & required
|
||||
or any(value.startswith("owner:") for value in item["coverage"] & required)]
|
||||
owner = owner_items[0] if owner_items else (selected[0] if selected else None)
|
||||
reviewers = [item for item in selected if item is not owner and "independent-review" in item["coverage"]]
|
||||
contributors = [item for item in selected if item is not owner and item not in reviewers]
|
||||
|
||||
def public(item: dict[str, Any], assignment: str) -> dict[str, Any]:
|
||||
return {
|
||||
"role-id": item["role"]["role-id"],
|
||||
"family-id": item["family"]["family-id"],
|
||||
"assignment": assignment,
|
||||
"coverage": sorted(item["coverage"] & required),
|
||||
"score": item["score"],
|
||||
"reason": "minimum marginal coverage under token and independence constraints",
|
||||
}
|
||||
|
||||
all_candidate_ids = [role_id for family_id in family_ids
|
||||
for role_id in families[family_id].get("member-role-ids", []) or []]
|
||||
skipped = []
|
||||
for role_id in all_candidate_ids:
|
||||
if role_id in selected_ids:
|
||||
continue
|
||||
skipped.append({
|
||||
"role-id": role_id,
|
||||
"reason": "coverage already satisfied by a lower-cost or higher-gain concrete role",
|
||||
})
|
||||
estimate = estimate_plan([item["role"] for item in selected], tier, stage)
|
||||
covered = required - uncovered
|
||||
result = {
|
||||
"selection-plan": {
|
||||
"version": 1,
|
||||
"workflow-id": profile.get("workflow-id"),
|
||||
"tier": tier,
|
||||
"workflow-stage": stage,
|
||||
"candidate-families": family_ids,
|
||||
"selected": {
|
||||
"owner": public(owner, "owner") if owner else None,
|
||||
"contributors": [public(item, "contributor") for item in contributors],
|
||||
"reviewers": [public(item, "independent-reviewer") for item in reviewers],
|
||||
},
|
||||
"skipped": skipped,
|
||||
"coverage": {
|
||||
"required": sorted(required),
|
||||
"already-covered": sorted(required & effective_existing),
|
||||
"covered": sorted(covered),
|
||||
"missing": sorted(uncovered),
|
||||
},
|
||||
"estimated-tokens": estimate,
|
||||
"budget": {"max-total": budget, "within-budget": estimate["total"] <= budget},
|
||||
"status": "ready" if not uncovered and estimate["total"] <= budget else "blocked",
|
||||
"policy": {
|
||||
"score-formula": scorecard.get("total-score-formula"),
|
||||
"decision-thresholds": scorecard.get("decision-thresholds"),
|
||||
"max-concurrent-role-agents": ((execution_policy.get("wave") or {}).get("max-concurrent-role-agents") or 5),
|
||||
"sources": ["role-selection-scorecard.yaml", "execution-policy.yaml", "governance-tiers.yaml"],
|
||||
},
|
||||
}
|
||||
}
|
||||
result["selection-plan"]["task-graph"] = build_task_graph(result["selection-plan"])
|
||||
return result
|
||||
|
||||
|
||||
def resolve_family(family_id: str, signals: list[str] | None = None, tier: str = "standard") -> dict[str, Any] | None:
|
||||
profile = {
|
||||
"candidate-families": [family_id],
|
||||
"signals": signals or [],
|
||||
"tier": tier,
|
||||
"independent-review-required": False,
|
||||
"max-selected-roles": 1 if tier in {"light", "standard"} else 3,
|
||||
}
|
||||
plan = select_minimum_sufficient_roles(profile)["selection-plan"]
|
||||
owner = (plan.get("selected") or {}).get("owner")
|
||||
if not owner:
|
||||
return None
|
||||
selected = [owner] + (plan["selected"].get("contributors") or [])
|
||||
workers = [item["role-id"] for item in selected]
|
||||
_, families, _, _, _ = _registries()
|
||||
family = families.get(family_id)
|
||||
return {
|
||||
"requested-family": family_id,
|
||||
"resolved-workers": workers,
|
||||
"primary-worker": workers[0],
|
||||
"available-workers": list((family or {}).get("member-role-ids", []) or []),
|
||||
"routing-reason": "minimum-sufficient-coverage",
|
||||
"collaboration-default": (family or {}).get("collaboration-default"),
|
||||
"selection-plan": plan,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Compile a role selection plan into a small dependency graph."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_task_graph(selection_plan: dict[str, Any]) -> dict[str, Any]:
|
||||
selected = selection_plan.get("selected") or {}
|
||||
producers = []
|
||||
for group in ("owner", "contributors"):
|
||||
value = selected.get(group)
|
||||
items = value if isinstance(value, list) else ([value] if value else [])
|
||||
for item in items:
|
||||
producers.append(item["role-id"] if isinstance(item, dict) else str(item))
|
||||
reviewers = [item["role-id"] if isinstance(item, dict) else str(item)
|
||||
for item in selected.get("reviewers", []) or []]
|
||||
nodes = []
|
||||
for role_id in producers:
|
||||
nodes.append({"task-id": f"produce:{role_id}", "role-id": role_id, "depends-on": []})
|
||||
producer_nodes = [node["task-id"] for node in nodes]
|
||||
for role_id in reviewers:
|
||||
nodes.append({"task-id": f"review:{role_id}", "role-id": role_id,
|
||||
"depends-on": producer_nodes, "independent": True})
|
||||
if len(nodes) > 1:
|
||||
nodes.append({"task-id": "synthesize", "role-id": selection_plan.get("synthesis-role") or "OPS-ORCH",
|
||||
"depends-on": [node["task-id"] for node in nodes], "reads": "projection-first"})
|
||||
return {"workflow-id": selection_plan.get("workflow-id"), "nodes": nodes}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Reusable state-kernel services behind the compatibility ``state_engine.py`` CLI."""
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Append-only JSONL primitives for the Org OS state kernel."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import nullcontext
|
||||
from typing import Any, Callable, ContextManager, Iterable
|
||||
|
||||
|
||||
def read_jsonl(path: str | None, on_error: Callable[[str], None] | None = None) -> list[dict[str, Any]]:
|
||||
if not path or not os.path.exists(path):
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
rows.append(value)
|
||||
except Exception as exc:
|
||||
if on_error:
|
||||
on_error(f"event 원장 읽기 실패({path}): {exc}")
|
||||
return rows
|
||||
|
||||
|
||||
def append_jsonl(
|
||||
path: str | None,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
file_lock: bool = False,
|
||||
on_error: Callable[[str], None] | None = None,
|
||||
) -> bool:
|
||||
if not path:
|
||||
return False
|
||||
try:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
if file_lock:
|
||||
try:
|
||||
import fcntl
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
except Exception:
|
||||
pass
|
||||
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
return True
|
||||
except Exception as exc:
|
||||
if on_error:
|
||||
on_error(f"event append 실패({path}): {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def atomic_append(
|
||||
entries: Iterable[tuple[str | None, dict[str, Any]]],
|
||||
*,
|
||||
transaction_lock: ContextManager[Any] | None = None,
|
||||
) -> None:
|
||||
"""Append a group of events and truncate every participating tail on failure."""
|
||||
normalized = list(entries)
|
||||
handles: list[tuple[Any, int]] = []
|
||||
with (transaction_lock or nullcontext()):
|
||||
try:
|
||||
for path, _event in normalized:
|
||||
if not path:
|
||||
raise OSError("event path 해석 실패")
|
||||
handle = open(path, "a+", encoding="utf-8")
|
||||
try:
|
||||
import fcntl
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
except Exception:
|
||||
pass
|
||||
handle.seek(0, os.SEEK_END)
|
||||
handles.append((handle, handle.tell()))
|
||||
try:
|
||||
for (handle, _offset), (_path, event) in zip(handles, normalized):
|
||||
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except Exception:
|
||||
for handle, offset in handles:
|
||||
handle.seek(offset)
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
raise
|
||||
finally:
|
||||
for handle, _offset in handles:
|
||||
try:
|
||||
handle.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Deterministic workflow materialized-view projection from canonical events."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
PROTECTED_FIELDS = (
|
||||
"quality_gate_status", "quality-gate-status",
|
||||
"release_acceptance_status", "release-acceptance-status",
|
||||
"unresolved_critical_risks", "unresolved-critical-risks",
|
||||
"blocker-open", "blocker_open", "resume-condition",
|
||||
"resume_condition_present", "resume-condition-satisfied",
|
||||
"resume_condition_satisfied", "evidence-grade", "evidence_grade",
|
||||
"human_gate_approved", "human-gate-approved",
|
||||
"current-quality-event-id", "current-quality-event-ids",
|
||||
"current-quality-artifact-id", "current-quality-artifact-sha256",
|
||||
"current-completion-artifact-id", "current-completion-artifact-sha256",
|
||||
"quality-panel-unmet",
|
||||
)
|
||||
|
||||
|
||||
def _latest_artifact_of_kind(artifacts: list[dict[str, Any]], kind: str) -> dict[str, Any] | None:
|
||||
return next((artifact for artifact in reversed(artifacts or [])
|
||||
if isinstance(artifact, dict) and artifact.get("artifact-kind") == kind), None)
|
||||
|
||||
|
||||
def project_workflow(
|
||||
ledger: dict[str, Any],
|
||||
*,
|
||||
trusted_artifacts: list[dict[str, Any]],
|
||||
workflow_events: list[dict[str, Any]],
|
||||
initial_stage: Callable[[str], str],
|
||||
quality_panel_unmet: Callable[[str, list[dict[str, Any]], list[dict[str, Any]]], list[str]],
|
||||
default_plan: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Rebuild protected fields; caller-owned data is never accepted as runtime truth."""
|
||||
projected = dict(ledger)
|
||||
for key in PROTECTED_FIELDS:
|
||||
projected.pop(key, None)
|
||||
projected["artifacts"] = trusted_artifacts
|
||||
quality_events: list[dict[str, Any]] = []
|
||||
release_events: list[dict[str, Any]] = []
|
||||
for event in workflow_events:
|
||||
event_type = event.get("event-type")
|
||||
if event_type == "workflow-initialized":
|
||||
projected["stage"] = event.get("stage") or initial_stage(event.get("plan", default_plan))
|
||||
projected["stage-status"] = "running"
|
||||
projected["last-completed-stage"] = None
|
||||
projected["completed-for-next-stage"] = None
|
||||
projected.pop("blocked-from", None)
|
||||
projected.pop("design-direction-approval", None)
|
||||
projected.pop("experience-foundation-approval", None)
|
||||
for key in (
|
||||
"plan", "tier", "mode", "evidence-contract-version",
|
||||
"parent-workflow-id", "product-decision-id",
|
||||
"direction-input-brief-ref", "direction-input-brief-sha256",
|
||||
):
|
||||
if event.get(key) is not None:
|
||||
projected[key] = event.get(key)
|
||||
elif event_type == "state-transition":
|
||||
projected["stage"] = event.get("to") or projected.get("stage")
|
||||
projected["stage-status"] = "running"
|
||||
projected["last-completed-stage"] = event.get("from")
|
||||
projected["completed-for-next-stage"] = None
|
||||
if event.get("to") == "blocked":
|
||||
projected["blocked-from"] = event.get("from")
|
||||
elif event.get("from") == "blocked":
|
||||
projected.pop("blocked-from", None)
|
||||
elif event_type == "stage-completed":
|
||||
if event.get("stage") == projected.get("stage"):
|
||||
projected["stage-status"] = "completed"
|
||||
projected["last-completed-stage"] = event.get("stage")
|
||||
projected["completed-for-next-stage"] = event.get("intended-next-stage")
|
||||
elif event_type == "quality-gate-recorded":
|
||||
quality_events.append(event)
|
||||
elif event_type == "release-decision-recorded":
|
||||
release_events.append(event)
|
||||
elif event_type == "tier-escalated":
|
||||
projected["tier"] = event.get("to-tier") or projected.get("tier")
|
||||
elif event_type == "workflow-blocked":
|
||||
projected["blocked-from"] = event.get("blocked-from") or projected.get("stage")
|
||||
projected["stage"] = "blocked"
|
||||
projected["stage-status"] = "running"
|
||||
projected["completed-for-next-stage"] = None
|
||||
projected["blocker-open"] = True
|
||||
projected["resume-condition"] = event.get("resume-condition")
|
||||
projected.pop("resume-condition-satisfied", None)
|
||||
elif event_type == "workflow-resumed":
|
||||
projected["stage"] = event.get("to") or projected.get("blocked-from") or projected.get("stage")
|
||||
projected["stage-status"] = "running"
|
||||
projected["completed-for-next-stage"] = None
|
||||
projected.pop("blocked-from", None)
|
||||
projected["blocker-open"] = False
|
||||
projected["resume-condition-satisfied"] = True
|
||||
elif event_type == "direction-approval-registered":
|
||||
projected["design-direction-approval"] = {
|
||||
"report-ref": event.get("report-ref"),
|
||||
"report-sha256": event.get("report-sha256"),
|
||||
"child-workflow-id": event.get("child-workflow-id"),
|
||||
}
|
||||
elif event_type == "experience-foundation-registered":
|
||||
projected["experience-foundation-approval"] = {
|
||||
"child-workflow-id": event.get("child-workflow-id"),
|
||||
"product-decision-id": event.get("product-decision-id"),
|
||||
"benchmark-id": event.get("benchmark-id"),
|
||||
"benchmark-ref": event.get("benchmark-ref"),
|
||||
"benchmark-sha256": event.get("benchmark-sha256"),
|
||||
"strategy-id": event.get("strategy-id"),
|
||||
"strategy-ref": event.get("strategy-ref"),
|
||||
"strategy-sha256": event.get("strategy-sha256"),
|
||||
"technical-id": event.get("technical-id"),
|
||||
"technical-ref": event.get("technical-ref"),
|
||||
"technical-sha256": event.get("technical-sha256"),
|
||||
"operational-id": event.get("operational-id"),
|
||||
"operational-ref": event.get("operational-ref"),
|
||||
"operational-sha256": event.get("operational-sha256"),
|
||||
"blueprint-id": event.get("blueprint-id"),
|
||||
"blueprint-ref": event.get("blueprint-ref"),
|
||||
"blueprint-sha256": event.get("blueprint-sha256"),
|
||||
"wireframe-id": event.get("wireframe-id"),
|
||||
"wireframe-ref": event.get("wireframe-ref"),
|
||||
"wireframe-sha256": event.get("wireframe-sha256"),
|
||||
}
|
||||
|
||||
completion = _latest_artifact_of_kind(trusted_artifacts, "completion-record")
|
||||
if not completion:
|
||||
return projected
|
||||
completion_id = completion.get("artifact-id")
|
||||
completion_sha = completion.get("artifact-sha256")
|
||||
projected["current-completion-artifact-id"] = completion_id
|
||||
projected["current-completion-artifact-sha256"] = completion_sha
|
||||
current_quality = [
|
||||
event for event in quality_events
|
||||
if event.get("reviewed-artifact-id") == completion_id
|
||||
and event.get("reviewed-artifact-sha256") == completion_sha
|
||||
and any(
|
||||
artifact.get("artifact-id") == event.get("review-artifact-id")
|
||||
and artifact.get("artifact-sha256") == event.get("review-artifact-sha256")
|
||||
for artifact in trusted_artifacts
|
||||
)
|
||||
]
|
||||
latest_by_actor = {event.get("actor"): event for event in current_quality}
|
||||
active_quality = list(latest_by_actor.values())
|
||||
if active_quality:
|
||||
failed = any(event.get("status") != "Passed" or event.get("blocker-open")
|
||||
for event in active_quality)
|
||||
projected["quality_gate_status"] = "Failed" if failed else "Passed"
|
||||
projected["blocker-open"] = bool(projected.get("blocker-open")) or any(
|
||||
bool(event.get("blocker-open")) for event in active_quality)
|
||||
last_quality = active_quality[-1]
|
||||
projected["current-quality-event-id"] = last_quality.get("workflow-event-id")
|
||||
projected["current-quality-event-ids"] = sorted(
|
||||
event.get("workflow-event-id") for event in active_quality if event.get("workflow-event-id")
|
||||
)
|
||||
projected["current-quality-artifact-id"] = last_quality.get("review-artifact-id")
|
||||
projected["current-quality-artifact-sha256"] = last_quality.get("review-artifact-sha256")
|
||||
panel_unmet = quality_panel_unmet(projected.get("tier"), trusted_artifacts, active_quality)
|
||||
if panel_unmet:
|
||||
projected["quality-panel-unmet"] = panel_unmet
|
||||
projected.pop("quality_gate_status", None)
|
||||
active_event_ids = projected.get("current-quality-event-ids") or []
|
||||
for event in release_events:
|
||||
if (event.get("reviewed-completion-artifact-id") != completion_id
|
||||
or event.get("reviewed-completion-artifact-sha256") != completion_sha
|
||||
or sorted(event.get("quality-event-set") or []) != active_event_ids):
|
||||
continue
|
||||
if event.get("status") == "Approved" and (
|
||||
projected.get("quality_gate_status") != "Passed" or projected.get("blocker-open")):
|
||||
continue
|
||||
projected["release_acceptance_status"] = event.get("status")
|
||||
projected["unresolved_critical_risks"] = bool(event.get("unresolved-critical-risks"))
|
||||
return projected
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Pure workflow transition evaluation; state I/O remains outside this module."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def structural_next(
|
||||
current: str,
|
||||
plan_stages: list[str],
|
||||
transitions: list[dict[str, Any]],
|
||||
blocked_from: str | None = None,
|
||||
) -> list[str]:
|
||||
destinations: set[str] = set()
|
||||
if current in plan_stages:
|
||||
index = plan_stages.index(current)
|
||||
if index + 1 < len(plan_stages):
|
||||
destinations.add(plan_stages[index + 1])
|
||||
for transition in transitions:
|
||||
source = transition.get("from")
|
||||
destination = transition.get("to")
|
||||
if source == current:
|
||||
if destination == "<resume>":
|
||||
if blocked_from:
|
||||
destinations.add(blocked_from)
|
||||
elif destination:
|
||||
destinations.add(destination)
|
||||
elif source == "*" and current not in ("blocked", "closed") and destination:
|
||||
destinations.add(destination)
|
||||
destinations.discard("<resume>")
|
||||
destinations.discard("*")
|
||||
return sorted(destinations)
|
||||
|
||||
|
||||
def actor_allowed(
|
||||
transition: dict[str, Any],
|
||||
actor: str | None,
|
||||
role_registry: dict[str, Any],
|
||||
) -> tuple[bool, str | None]:
|
||||
allowed = transition.get("allowed-by") or []
|
||||
if not str(actor or "").strip():
|
||||
return False, "전이 주체(--actor) 미지정 — 누가 전이하는지 명시해야 한다(권한/감사)."
|
||||
if not allowed:
|
||||
return True, None
|
||||
normalized = str(actor).strip()
|
||||
if normalized not in role_registry:
|
||||
return False, f"actor '{normalized}'는 role registry에 없는 역할이다"
|
||||
if isinstance(allowed, dict):
|
||||
concrete = allowed.get("executor") or allowed.get("concrete-roles") or []
|
||||
else:
|
||||
concrete = [value for value in allowed
|
||||
if isinstance(value, str) and not value.endswith("-role-agent")]
|
||||
if normalized in concrete:
|
||||
return True, None
|
||||
if not concrete:
|
||||
return False, f"allowed-by가 placeholder만 포함해 runtime 권한을 결정할 수 없다: {allowed}"
|
||||
return False, f"actor '{actor}' 는 transition executor {concrete} 에 없다(권한 없음)."
|
||||
|
||||
|
||||
def evaluate_transition(
|
||||
transition: dict[str, Any],
|
||||
*,
|
||||
current: str,
|
||||
destination: str,
|
||||
plan: str,
|
||||
plan_stages: set[str],
|
||||
blocked_from: str | None,
|
||||
facts: dict[str, Any],
|
||||
condition_evaluator: Callable[[Any, dict[str, Any]], tuple[bool, str | None]],
|
||||
actor: str | None = None,
|
||||
role_registry: dict[str, Any] | None = None,
|
||||
) -> list[str]:
|
||||
universal = (destination == "blocked" or current == "blocked"
|
||||
or destination == current or transition.get("from") == "*")
|
||||
if plan_stages and not universal and destination not in plan_stages:
|
||||
return [f"plan '{plan}' 시퀀스에 없는 stage 전이 금지: {current} -> {destination} "
|
||||
f"(plan stages: {sorted(plan_stages)})"]
|
||||
if actor:
|
||||
permitted, reason = actor_allowed(transition, actor, role_registry or {})
|
||||
if not permitted:
|
||||
return [reason or "transition actor 권한 없음"]
|
||||
if current == "blocked" and blocked_from and destination != blocked_from:
|
||||
return [f"blocked 재개 대상은 {blocked_from} 여야 합니다(요청: {destination})"]
|
||||
reasons: list[str] = []
|
||||
for condition in transition.get("required-conditions") or []:
|
||||
passed, reason = condition_evaluator(condition, facts)
|
||||
if not passed:
|
||||
reasons.append(reason or f"조건 미충족: {condition}")
|
||||
for condition in transition.get("forbidden-if") or []:
|
||||
passed, _reason = condition_evaluator(condition, facts)
|
||||
if passed:
|
||||
reasons.append(f"금지조건 충족: {condition}")
|
||||
return reasons
|
||||
|
||||
Reference in New Issue
Block a user