config.py
python
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199
fix(ISR): default require_independent_second_reviewer to require
Human
minor
⚠ breaking
5 hours ago
| 1 | """`.overseer/config.yaml` schema validation — fail-closed on unknown version/regime.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from dataclasses import dataclass |
| 6 | from pathlib import Path |
| 7 | from typing import Any |
| 8 | |
| 9 | import yaml |
| 10 | |
| 11 | from adapters.errors import ConfigError |
| 12 | |
| 13 | SUPPORTED_CONFIG_VERSION = 1 |
| 14 | SUPPORTED_REGIMES = frozenset({"muse+git-mirror", "muse-only", "git-only"}) |
| 15 | SUPPORTED_CANONICAL = frozenset({"muse", "git"}) |
| 16 | REVIEWER_MODES = frozenset({"agent", "human"}) |
| 17 | REVIEWER_PROVIDERS = frozenset({"local", "api"}) |
| 18 | REVIEWER_FALLBACK = frozenset({"human"}) |
| 19 | HUMAN_ESCALATION_TOKENS = frozenset({"security", "irreversible", "real_money", "gates_tier3"}) |
| 20 | DEFAULT_REVIEWER_MODEL = "thinking-high" |
| 21 | DEFAULT_REVIEWER_PROVIDER = "local" |
| 22 | DEFAULT_REVIEWER_FALLBACK = "human" |
| 23 | REVIEWER_MAPPING_KEYS = frozenset({"mode", "model", "provider", "fallback"}) |
| 24 | CHECKPOINTS_KEYS = frozenset( |
| 25 | { |
| 26 | "enabled", |
| 27 | "policy", |
| 28 | "active_manifest", |
| 29 | "progress", |
| 30 | "orchestrator", |
| 31 | "allow_hand_verified", |
| 32 | } |
| 33 | ) |
| 34 | HONESTY_KEYS = frozenset( |
| 35 | { |
| 36 | "enabled", |
| 37 | "ledger", |
| 38 | "roles_file", |
| 39 | "require_verdict_on", |
| 40 | "require_l1_evidence", |
| 41 | "require_verification_evidence", |
| 42 | "require_deploy_health", |
| 43 | "require_independent_second_reviewer", |
| 44 | "allow_signed_approval", |
| 45 | "ci_reexecutor", |
| 46 | "require_agent_signature", |
| 47 | } |
| 48 | ) |
| 49 | MODULES_GOVERNANCE_KEYS = frozenset({"enabled"}) |
| 50 | MODULES_CHECKPOINTS_KEYS = frozenset({"enabled"}) |
| 51 | MODULES_HONESTY_KEYS = frozenset({"enabled"}) |
| 52 | EXTENSION_KEYS = frozenset({"id", "schema_version", "config_path"}) |
| 53 | HOOK_NAMES = frozenset({"board_done", "handoff", "register"}) |
| 54 | GOVERNANCE_GATES_SURFACES = frozenset({"status", "governance-sync", "handover-paste"}) |
| 55 | GOVERNANCE_GATES_KEYS = frozenset( |
| 56 | { |
| 57 | "remind", |
| 58 | "freeze_review", |
| 59 | "build_verification", |
| 60 | "surfaces", |
| 61 | } |
| 62 | ) |
| 63 | L1_EVIDENCE_MODES = frozenset({"off", "warn", "require"}) |
| 64 | MODEL_ROUTING_KEYS = frozenset({"enabled", "policy"}) |
| 65 | DEFAULT_MODEL_ROUTING_POLICY = "policy/model-routing.yaml" |
| 66 | COST_AWARENESS_SURFACES = frozenset({"status", "governance-sync"}) |
| 67 | COST_AWARENESS_KEYS = frozenset({"enabled", "surfaces"}) |
| 68 | SESSION_BOOKENDS_KEYS = frozenset({"enabled"}) |
| 69 | WORKSPACE_KEYS = frozenset({"constellation_id", "product_order_root", "manifest"}) |
| 70 | |
| 71 | # Hosted dashboard keys live in tools.hosted_dashboard.config (import deferred to avoid cycles). |
| 72 | |
| 73 | |
| 74 | @dataclass(frozen=True) |
| 75 | class GitConfig: |
| 76 | remote: str |
| 77 | main_branch: str |
| 78 | mirror_branch: str | None |
| 79 | feature_branch_pattern: str |
| 80 | |
| 81 | |
| 82 | @dataclass(frozen=True) |
| 83 | class MuseConfig: |
| 84 | staging_remote: str | None |
| 85 | main_branch: str | None |
| 86 | working_dir: str | None = None |
| 87 | |
| 88 | |
| 89 | @dataclass(frozen=True) |
| 90 | class LaneDocsConfig: |
| 91 | """One handover + roadmap pair (K8 multi-lane).""" |
| 92 | |
| 93 | handover: str |
| 94 | roadmap: str |
| 95 | handover_title: str |
| 96 | roadmap_title: str |
| 97 | |
| 98 | |
| 99 | @dataclass(frozen=True) |
| 100 | class DocsConfig: |
| 101 | handover: str |
| 102 | roadmap: str |
| 103 | coordination: str | None |
| 104 | standing_decisions: str |
| 105 | handover_title: str |
| 106 | roadmap_title: str |
| 107 | default_lane: str | None = None |
| 108 | lanes: dict[str, LaneDocsConfig] | None = None |
| 109 | |
| 110 | |
| 111 | @dataclass(frozen=True) |
| 112 | class ThresholdsConfig: |
| 113 | realign_max_commits: int |
| 114 | drift_warn_only: bool |
| 115 | |
| 116 | |
| 117 | @dataclass(frozen=True) |
| 118 | class ReviewerConfig: |
| 119 | mode: str |
| 120 | model: str |
| 121 | provider: str |
| 122 | fallback: str |
| 123 | |
| 124 | |
| 125 | @dataclass(frozen=True) |
| 126 | class FreezeContractConfig: |
| 127 | enabled: bool |
| 128 | reviewer: ReviewerConfig |
| 129 | human_escalation: list[str] |
| 130 | |
| 131 | |
| 132 | @dataclass(frozen=True) |
| 133 | class RepoConfig: |
| 134 | name: str |
| 135 | root_relative_docs: str |
| 136 | |
| 137 | |
| 138 | @dataclass(frozen=True) |
| 139 | class VcsConfig: |
| 140 | regime: str |
| 141 | canonical: str |
| 142 | git: GitConfig |
| 143 | muse: MuseConfig |
| 144 | |
| 145 | |
| 146 | @dataclass(frozen=True) |
| 147 | class CheckpointsConfig: |
| 148 | """L1 checkpoint module settings (§K9.2).""" |
| 149 | |
| 150 | enabled: bool = False |
| 151 | policy: str | None = None |
| 152 | active_manifest: str | None = None |
| 153 | progress: str | None = None |
| 154 | orchestrator: str | None = None |
| 155 | allow_hand_verified: bool = False |
| 156 | |
| 157 | |
| 158 | @dataclass(frozen=True) |
| 159 | class HonestyConfig: |
| 160 | """L2 honesty module settings (§K9.2) — parsed for forward compat; K10 builds CLI.""" |
| 161 | |
| 162 | enabled: bool = False |
| 163 | ledger: str | None = None |
| 164 | roles_file: str | None = None |
| 165 | require_verdict_on: frozenset[str] = frozenset({"board_done", "handoff", "register"}) |
| 166 | require_l1_evidence: str = "warn" |
| 167 | require_verification_evidence: str = "off" |
| 168 | require_deploy_health: str = "off" |
| 169 | require_independent_second_reviewer: str = "require" |
| 170 | allow_signed_approval: bool = False |
| 171 | ci_reexecutor: str | None = None |
| 172 | require_agent_signature: bool = False |
| 173 | |
| 174 | |
| 175 | @dataclass(frozen=True) |
| 176 | class ModulesConfig: |
| 177 | """Optional mirror of section enable flags (§K9.2).""" |
| 178 | |
| 179 | governance_enabled: bool = True |
| 180 | checkpoints_enabled: bool | None = None |
| 181 | honesty_enabled: bool | None = None |
| 182 | |
| 183 | |
| 184 | @dataclass(frozen=True) |
| 185 | class GovernanceGatesConfig: |
| 186 | """Governance gate reminder settings (§KH1.9).""" |
| 187 | |
| 188 | remind: bool = True |
| 189 | freeze_review_required: bool = True |
| 190 | build_verification_required: bool = True |
| 191 | surfaces: frozenset[str] = frozenset(GOVERNANCE_GATES_SURFACES) |
| 192 | |
| 193 | |
| 194 | @dataclass(frozen=True) |
| 195 | class ModelRoutingConfig: |
| 196 | """Optional model-routing policy settings (§PR.5).""" |
| 197 | |
| 198 | enabled: bool = False |
| 199 | policy: str = DEFAULT_MODEL_ROUTING_POLICY |
| 200 | |
| 201 | |
| 202 | @dataclass(frozen=True) |
| 203 | class CostAwarenessConfig: |
| 204 | """Optional cost-awareness surface settings (§PC.6).""" |
| 205 | |
| 206 | enabled: bool = False |
| 207 | surfaces: frozenset[str] = frozenset(COST_AWARENESS_SURFACES) |
| 208 | |
| 209 | |
| 210 | @dataclass(frozen=True) |
| 211 | class SessionBookendsConfig: |
| 212 | """Optional Cursor session bookend hooks (§LT.4.2).""" |
| 213 | |
| 214 | enabled: bool = False |
| 215 | |
| 216 | |
| 217 | @dataclass(frozen=True) |
| 218 | class WorkspacePointerConfig: |
| 219 | """Additive constellation pointer (§MR.4.2). Omitted = single-repo only.""" |
| 220 | |
| 221 | constellation_id: str |
| 222 | product_order_root: str | None = None |
| 223 | manifest: str | None = None |
| 224 | |
| 225 | |
| 226 | @dataclass(frozen=True) |
| 227 | class HostedDashboardSection: |
| 228 | """Optional hosted_dashboard settings (§HGD.10.2) — opaque validated mapping holder.""" |
| 229 | |
| 230 | # Parsed by tools.hosted_dashboard.config; stored as validated object. |
| 231 | enabled: bool = False |
| 232 | allow_non_loopback: bool = False |
| 233 | cors_origins: tuple[str, ...] = () |
| 234 | org_allowlist: tuple[str, ...] = () |
| 235 | github_contents: bool = True |
| 236 | github_meta: bool = True |
| 237 | github_checks_advisory: bool = False |
| 238 | musehub_read: bool = False |
| 239 | |
| 240 | |
| 241 | @dataclass(frozen=True) |
| 242 | class PostLandSyncConfig: |
| 243 | """Optional ff-only local-main sync after a successful ``ok pr-land`` (§PLS.3). |
| 244 | |
| 245 | v1 closed vocabulary: ``strategy`` must be ``ff_only`` and |
| 246 | ``require_clean_worktree`` must be ``true`` — dirty-tree clobber is a non-goal. |
| 247 | Remote/branch identity comes from ``vcs.git.*`` (never duplicated here). |
| 248 | """ |
| 249 | |
| 250 | enabled: bool = False |
| 251 | strategy: str = "ff_only" |
| 252 | require_clean_worktree: bool = True |
| 253 | |
| 254 | |
| 255 | @dataclass(frozen=True) |
| 256 | class CloseRitualConfig: |
| 257 | """Fail-closed land-to-main hygiene (never auto-merges — Tier 3). |
| 258 | |
| 259 | ``verify_landed`` compares ``require_paths`` working-tree content to |
| 260 | ``origin/<main_branch>``. ``prepare_pr`` only reports dirty/uncommitted state. |
| 261 | ``post_land_sync`` is the additive PLS post-step on ``ok pr-land`` only. |
| 262 | """ |
| 263 | |
| 264 | enabled: bool = False |
| 265 | mode: str = "verify_landed" # verify_landed | prepare_pr |
| 266 | require_paths: tuple[str, ...] = () |
| 267 | consumer_verify_script: str | None = None # optional repo-relative script |
| 268 | post_land_sync: PostLandSyncConfig = PostLandSyncConfig() |
| 269 | |
| 270 | |
| 271 | @dataclass(frozen=True) |
| 272 | class ExtensionEntry: |
| 273 | """One extensions[] escape-hatch entry (§K9.2).""" |
| 274 | |
| 275 | id: str |
| 276 | schema_version: int |
| 277 | config_path: str |
| 278 | |
| 279 | |
| 280 | @dataclass(frozen=True) |
| 281 | class OverseerConfig: |
| 282 | overseer_config_version: int |
| 283 | repo: RepoConfig |
| 284 | vcs: VcsConfig |
| 285 | docs: DocsConfig |
| 286 | thresholds: ThresholdsConfig |
| 287 | freeze_contract: FreezeContractConfig |
| 288 | checkpoints: CheckpointsConfig = CheckpointsConfig() |
| 289 | honesty: HonestyConfig = HonestyConfig() |
| 290 | modules: ModulesConfig | None = None |
| 291 | extensions: tuple[ExtensionEntry, ...] = () |
| 292 | extension_warnings: tuple[str, ...] = () |
| 293 | governance_gates: GovernanceGatesConfig = GovernanceGatesConfig() |
| 294 | model_routing: ModelRoutingConfig = ModelRoutingConfig() |
| 295 | cost_awareness: CostAwarenessConfig = CostAwarenessConfig() |
| 296 | session_bookends: SessionBookendsConfig = SessionBookendsConfig() |
| 297 | hosted_dashboard: HostedDashboardSection = HostedDashboardSection() |
| 298 | close_ritual: CloseRitualConfig = CloseRitualConfig() |
| 299 | workspace: WorkspacePointerConfig | None = None |
| 300 | |
| 301 | |
| 302 | def load_config(path: Path) -> OverseerConfig: |
| 303 | """Parse and validate config; raise ``ConfigError`` on any violation.""" |
| 304 | path = path.resolve() |
| 305 | if not path.is_file(): |
| 306 | raise ConfigError("config file missing", str(path)) |
| 307 | |
| 308 | try: |
| 309 | raw_text = path.read_text(encoding="utf-8") |
| 310 | except OSError as exc: |
| 311 | raise ConfigError(f"cannot read config: {exc}", str(path)) from exc |
| 312 | |
| 313 | try: |
| 314 | raw = yaml.safe_load(raw_text) |
| 315 | except yaml.YAMLError as exc: |
| 316 | raise ConfigError(f"unparseable YAML: {exc}", str(path)) from exc |
| 317 | |
| 318 | if not isinstance(raw, dict): |
| 319 | raise ConfigError("config root must be a mapping", str(path)) |
| 320 | |
| 321 | return _validate_config(raw, str(path)) |
| 322 | |
| 323 | |
| 324 | def _require_mapping(data: Any, field: str, path: str) -> dict[str, Any]: |
| 325 | if not isinstance(data, dict): |
| 326 | raise ConfigError(f"{field} must be a mapping", path) |
| 327 | return data |
| 328 | |
| 329 | |
| 330 | def _require_str(data: dict[str, Any], field: str, path: str) -> str: |
| 331 | value = data.get(field) |
| 332 | if not isinstance(value, str) or not value.strip(): |
| 333 | raise ConfigError(f"{field} must be a non-empty string", path) |
| 334 | return value |
| 335 | |
| 336 | |
| 337 | def _optional_str(data: dict[str, Any], field: str) -> str | None: |
| 338 | value = data.get(field) |
| 339 | if value is None: |
| 340 | return None |
| 341 | if not isinstance(value, str): |
| 342 | raise ConfigError(f"{field} must be a string or null") |
| 343 | return value |
| 344 | |
| 345 | |
| 346 | def _validate_config(raw: dict[str, Any], path: str) -> OverseerConfig: |
| 347 | version = raw.get("overseer_config_version") |
| 348 | if not isinstance(version, int): |
| 349 | raise ConfigError("overseer_config_version must be an integer", path) |
| 350 | if version != SUPPORTED_CONFIG_VERSION: |
| 351 | raise ConfigError( |
| 352 | f"unsupported overseer_config_version {version} " |
| 353 | f"(supported: {SUPPORTED_CONFIG_VERSION})", |
| 354 | path, |
| 355 | ) |
| 356 | |
| 357 | repo_raw = _require_mapping(raw.get("repo"), "repo", path) |
| 358 | repo = RepoConfig( |
| 359 | name=_require_str(repo_raw, "name", path), |
| 360 | root_relative_docs=_require_str(repo_raw, "root_relative_docs", path), |
| 361 | ) |
| 362 | |
| 363 | vcs_raw = _require_mapping(raw.get("vcs"), "vcs", path) |
| 364 | regime = _require_str(vcs_raw, "regime", path) |
| 365 | if regime not in SUPPORTED_REGIMES: |
| 366 | raise ConfigError( |
| 367 | f"unsupported vcs.regime {regime!r} (supported: {sorted(SUPPORTED_REGIMES)})", |
| 368 | path, |
| 369 | ) |
| 370 | |
| 371 | canonical = _require_str(vcs_raw, "canonical", path) |
| 372 | if canonical not in SUPPORTED_CANONICAL: |
| 373 | raise ConfigError(f"unsupported vcs.canonical {canonical!r}", path) |
| 374 | |
| 375 | git_raw = _require_mapping(vcs_raw.get("git"), "vcs.git", path) |
| 376 | muse_raw = _require_mapping(vcs_raw.get("muse"), "vcs.muse", path) |
| 377 | |
| 378 | git = GitConfig( |
| 379 | remote=_require_str(git_raw, "remote", path), |
| 380 | main_branch=_require_str(git_raw, "main_branch", path), |
| 381 | mirror_branch=_optional_str(git_raw, "mirror_branch"), |
| 382 | feature_branch_pattern=_require_str(git_raw, "feature_branch_pattern", path), |
| 383 | ) |
| 384 | muse = MuseConfig( |
| 385 | staging_remote=_optional_str(muse_raw, "staging_remote"), |
| 386 | main_branch=_optional_str(muse_raw, "main_branch"), |
| 387 | working_dir=_optional_str(muse_raw, "working_dir"), |
| 388 | ) |
| 389 | _validate_muse_working_dir_shape(muse.working_dir, path) |
| 390 | |
| 391 | _validate_regime_fields(regime, canonical, git, muse, path) |
| 392 | |
| 393 | docs_raw = _require_mapping(raw.get("docs"), "docs", path) |
| 394 | lanes = _parse_lanes(docs_raw.get("lanes"), path) |
| 395 | default_lane = _optional_str(docs_raw, "default_lane") |
| 396 | handover = _require_str(docs_raw, "handover", path) |
| 397 | roadmap = _require_str(docs_raw, "roadmap", path) |
| 398 | handover_title = _optional_str(docs_raw, "handover_title") or "Overseer Handover" |
| 399 | roadmap_title = _optional_str(docs_raw, "roadmap_title") or "Roadmap" |
| 400 | _validate_lanes_config( |
| 401 | lanes=lanes, |
| 402 | default_lane=default_lane, |
| 403 | handover=handover, |
| 404 | roadmap=roadmap, |
| 405 | handover_title=handover_title, |
| 406 | roadmap_title=roadmap_title, |
| 407 | path=path, |
| 408 | ) |
| 409 | docs = DocsConfig( |
| 410 | handover=handover, |
| 411 | roadmap=roadmap, |
| 412 | coordination=_optional_str(docs_raw, "coordination"), |
| 413 | standing_decisions=_require_str(docs_raw, "standing_decisions", path), |
| 414 | handover_title=handover_title, |
| 415 | roadmap_title=roadmap_title, |
| 416 | default_lane=default_lane, |
| 417 | lanes=lanes, |
| 418 | ) |
| 419 | |
| 420 | thresholds_raw = _require_mapping(raw.get("thresholds"), "thresholds", path) |
| 421 | realign_max = thresholds_raw.get("realign_max_commits") |
| 422 | if not isinstance(realign_max, int) or realign_max < 1: |
| 423 | raise ConfigError("thresholds.realign_max_commits must be a positive integer", path) |
| 424 | drift_warn = thresholds_raw.get("drift_warn_only") |
| 425 | if not isinstance(drift_warn, bool): |
| 426 | raise ConfigError("thresholds.drift_warn_only must be a boolean", path) |
| 427 | thresholds = ThresholdsConfig( |
| 428 | realign_max_commits=realign_max, |
| 429 | drift_warn_only=drift_warn, |
| 430 | ) |
| 431 | |
| 432 | freeze_raw = _require_mapping(raw.get("freeze_contract"), "freeze_contract", path) |
| 433 | enabled = freeze_raw.get("enabled") |
| 434 | if not isinstance(enabled, bool): |
| 435 | raise ConfigError("freeze_contract.enabled must be a boolean", path) |
| 436 | reviewer = _parse_reviewer_config(freeze_raw.get("reviewer"), path) |
| 437 | escalation = freeze_raw.get("human_escalation") |
| 438 | if not isinstance(escalation, list) or not all(isinstance(x, str) for x in escalation): |
| 439 | raise ConfigError("freeze_contract.human_escalation must be a list of strings", path) |
| 440 | _validate_human_escalation(list(escalation), path) |
| 441 | |
| 442 | checkpoints, honesty, modules, extensions, extension_warnings = _parse_k9_modules(raw, path) |
| 443 | if honesty.require_agent_signature and regime == "git-only": |
| 444 | raise ConfigError( |
| 445 | "honesty.require_agent_signature is forbidden under git-only", |
| 446 | path, |
| 447 | exit_code=26, |
| 448 | ) |
| 449 | governance_gates = _parse_governance_gates(raw.get("governance_gates"), path) |
| 450 | model_routing = _parse_model_routing(raw.get("model_routing"), path) |
| 451 | cost_awareness = _parse_cost_awareness(raw.get("cost_awareness"), path) |
| 452 | session_bookends = _parse_session_bookends(raw.get("session_bookends"), path) |
| 453 | hosted_dashboard = _parse_hosted_dashboard(raw.get("hosted_dashboard"), path) |
| 454 | close_ritual = _parse_close_ritual(raw.get("close_ritual"), path) |
| 455 | workspace = _parse_workspace(raw.get("workspace"), path) |
| 456 | |
| 457 | return OverseerConfig( |
| 458 | overseer_config_version=version, |
| 459 | repo=repo, |
| 460 | vcs=VcsConfig(regime=regime, canonical=canonical, git=git, muse=muse), |
| 461 | docs=docs, |
| 462 | thresholds=thresholds, |
| 463 | freeze_contract=FreezeContractConfig( |
| 464 | enabled=enabled, |
| 465 | reviewer=reviewer, |
| 466 | human_escalation=list(escalation), |
| 467 | ), |
| 468 | checkpoints=checkpoints, |
| 469 | honesty=honesty, |
| 470 | modules=modules, |
| 471 | extensions=extensions, |
| 472 | extension_warnings=extension_warnings, |
| 473 | governance_gates=governance_gates, |
| 474 | model_routing=model_routing, |
| 475 | cost_awareness=cost_awareness, |
| 476 | session_bookends=session_bookends, |
| 477 | hosted_dashboard=hosted_dashboard, |
| 478 | close_ritual=close_ritual, |
| 479 | workspace=workspace, |
| 480 | ) |
| 481 | |
| 482 | |
| 483 | def _validate_muse_working_dir_shape(working_dir: str | None, path: str) -> None: |
| 484 | """Fail closed on absolute / ``..`` working_dir values (§K6.5.1).""" |
| 485 | if working_dir is None: |
| 486 | return |
| 487 | text = working_dir.strip() |
| 488 | if not text: |
| 489 | raise ConfigError("vcs.muse.working_dir must be a non-empty string or null", path) |
| 490 | candidate = Path(text) |
| 491 | if candidate.is_absolute(): |
| 492 | raise ConfigError("vcs.muse.working_dir must be relative to the install root", path) |
| 493 | if ".." in candidate.parts: |
| 494 | raise ConfigError("vcs.muse.working_dir must not contain '..' path segments", path) |
| 495 | |
| 496 | |
| 497 | def _validate_human_escalation(tokens: list[str], path: str) -> None: |
| 498 | for token in tokens: |
| 499 | if token not in HUMAN_ESCALATION_TOKENS: |
| 500 | raise ConfigError( |
| 501 | f"unknown freeze_contract.human_escalation token {token!r}", |
| 502 | path, |
| 503 | ) |
| 504 | |
| 505 | |
| 506 | def _parse_reviewer_config(raw_reviewer: Any, path: str) -> ReviewerConfig: |
| 507 | """Parse nested reviewer mapping or legacy string (§K5.3).""" |
| 508 | if isinstance(raw_reviewer, str): |
| 509 | if raw_reviewer not in REVIEWER_MODES: |
| 510 | raise ConfigError( |
| 511 | f"freeze_contract.reviewer legacy string must be agent|human, got {raw_reviewer!r}", |
| 512 | path, |
| 513 | ) |
| 514 | return ReviewerConfig( |
| 515 | mode=raw_reviewer, |
| 516 | model=DEFAULT_REVIEWER_MODEL, |
| 517 | provider=DEFAULT_REVIEWER_PROVIDER, |
| 518 | fallback=DEFAULT_REVIEWER_FALLBACK, |
| 519 | ) |
| 520 | |
| 521 | reviewer_raw = _require_mapping(raw_reviewer, "freeze_contract.reviewer", path) |
| 522 | extra = set(reviewer_raw) - REVIEWER_MAPPING_KEYS |
| 523 | if extra: |
| 524 | raise ConfigError( |
| 525 | f"unknown freeze_contract.reviewer keys: {sorted(extra)}", |
| 526 | path, |
| 527 | ) |
| 528 | |
| 529 | mode = _require_str(reviewer_raw, "mode", path) |
| 530 | if mode not in REVIEWER_MODES: |
| 531 | raise ConfigError(f"freeze_contract.reviewer.mode must be agent|human", path) |
| 532 | |
| 533 | if mode == "agent": |
| 534 | # §K5.3: all required fields for agent mode must be present; missing → 2. |
| 535 | for field_name in ("model", "provider", "fallback"): |
| 536 | if field_name not in reviewer_raw: |
| 537 | raise ConfigError( |
| 538 | f"freeze_contract.reviewer.{field_name} is required when mode is agent", |
| 539 | path, |
| 540 | ) |
| 541 | model = reviewer_raw["model"] |
| 542 | provider = reviewer_raw["provider"] |
| 543 | fallback = reviewer_raw["fallback"] |
| 544 | if not isinstance(model, str) or not model.strip(): |
| 545 | raise ConfigError("freeze_contract.reviewer.model must be a non-empty string", path) |
| 546 | if provider not in REVIEWER_PROVIDERS: |
| 547 | raise ConfigError("freeze_contract.reviewer.provider must be local|api", path) |
| 548 | if fallback not in REVIEWER_FALLBACK: |
| 549 | raise ConfigError("freeze_contract.reviewer.fallback must be human", path) |
| 550 | else: |
| 551 | # Human mode: model/provider/fallback optional; unused at runtime (§K5.2 step 6). |
| 552 | model = reviewer_raw.get("model", DEFAULT_REVIEWER_MODEL) |
| 553 | provider = reviewer_raw.get("provider", DEFAULT_REVIEWER_PROVIDER) |
| 554 | fallback = reviewer_raw.get("fallback", DEFAULT_REVIEWER_FALLBACK) |
| 555 | if not isinstance(model, str) or not model.strip(): |
| 556 | model = DEFAULT_REVIEWER_MODEL |
| 557 | if provider not in REVIEWER_PROVIDERS: |
| 558 | provider = DEFAULT_REVIEWER_PROVIDER |
| 559 | if fallback not in REVIEWER_FALLBACK: |
| 560 | fallback = DEFAULT_REVIEWER_FALLBACK |
| 561 | |
| 562 | return ReviewerConfig( |
| 563 | mode=mode, |
| 564 | model=model, |
| 565 | provider=provider, |
| 566 | fallback=fallback, |
| 567 | ) |
| 568 | |
| 569 | |
| 570 | def _parse_lanes(raw_lanes: Any, path: str) -> dict[str, LaneDocsConfig] | None: |
| 571 | """Parse optional ``docs.lanes`` mapping (§K8).""" |
| 572 | if raw_lanes is None: |
| 573 | return None |
| 574 | if not isinstance(raw_lanes, dict): |
| 575 | raise ConfigError("docs.lanes must be a mapping", path) |
| 576 | lanes: dict[str, LaneDocsConfig] = {} |
| 577 | for lane_name, lane_raw in raw_lanes.items(): |
| 578 | if not isinstance(lane_name, str) or not lane_name.strip(): |
| 579 | raise ConfigError("docs.lanes keys must be non-empty strings", path) |
| 580 | lane_map = _require_mapping(lane_raw, f"docs.lanes.{lane_name}", path) |
| 581 | lanes[lane_name.strip()] = LaneDocsConfig( |
| 582 | handover=_require_str(lane_map, "handover", path), |
| 583 | roadmap=_require_str(lane_map, "roadmap", path), |
| 584 | handover_title=_optional_str(lane_map, "handover_title") or "Overseer Handover", |
| 585 | roadmap_title=_optional_str(lane_map, "roadmap_title") or "Roadmap", |
| 586 | ) |
| 587 | return lanes |
| 588 | |
| 589 | |
| 590 | def _validate_lanes_config( |
| 591 | *, |
| 592 | lanes: dict[str, LaneDocsConfig] | None, |
| 593 | default_lane: str | None, |
| 594 | handover: str, |
| 595 | roadmap: str, |
| 596 | handover_title: str, |
| 597 | roadmap_title: str, |
| 598 | path: str, |
| 599 | ) -> None: |
| 600 | """Fail closed on inconsistent multi-lane docs config (§K8).""" |
| 601 | if lanes is None: |
| 602 | if default_lane is not None: |
| 603 | raise ConfigError("docs.default_lane requires docs.lanes", path) |
| 604 | return |
| 605 | if not lanes: |
| 606 | raise ConfigError("docs.lanes must not be empty when present", path) |
| 607 | if not default_lane or not default_lane.strip(): |
| 608 | raise ConfigError("docs.default_lane is required when docs.lanes is set", path) |
| 609 | default_lane = default_lane.strip() |
| 610 | if default_lane not in lanes: |
| 611 | raise ConfigError( |
| 612 | f"docs.default_lane {default_lane!r} is not a key in docs.lanes", |
| 613 | path, |
| 614 | ) |
| 615 | default = lanes[default_lane] |
| 616 | if handover != default.handover or roadmap != default.roadmap: |
| 617 | raise ConfigError( |
| 618 | "docs.handover and docs.roadmap must match docs.lanes[default_lane]", |
| 619 | path, |
| 620 | ) |
| 621 | if handover_title != default.handover_title or roadmap_title != default.roadmap_title: |
| 622 | raise ConfigError( |
| 623 | "docs.handover_title and docs.roadmap_title must match docs.lanes[default_lane]", |
| 624 | path, |
| 625 | ) |
| 626 | |
| 627 | |
| 628 | def resolve_lane_docs(config: OverseerConfig, lane: str | None) -> LaneDocsConfig: |
| 629 | """Return the handover/roadmap pair for ``lane`` or the default lane (§K8).""" |
| 630 | docs = config.docs |
| 631 | if docs.lanes is None: |
| 632 | if lane is not None: |
| 633 | raise ConfigError("docs.lanes is not configured; --lane is not supported") |
| 634 | return LaneDocsConfig( |
| 635 | handover=docs.handover, |
| 636 | roadmap=docs.roadmap, |
| 637 | handover_title=docs.handover_title, |
| 638 | roadmap_title=docs.roadmap_title, |
| 639 | ) |
| 640 | name = (lane or docs.default_lane or "").strip() |
| 641 | if name not in docs.lanes: |
| 642 | known = ", ".join(sorted(docs.lanes)) |
| 643 | raise ConfigError(f"unknown lane {name!r} (configured: {known})") |
| 644 | return docs.lanes[name] |
| 645 | |
| 646 | |
| 647 | def list_configured_lanes(config: OverseerConfig) -> tuple[str, ...]: |
| 648 | """Return sorted lane names; single implicit lane when ``docs.lanes`` is absent.""" |
| 649 | if config.docs.lanes is None: |
| 650 | return ("default",) |
| 651 | return tuple(sorted(config.docs.lanes)) |
| 652 | |
| 653 | |
| 654 | def _validate_regime_fields( |
| 655 | regime: str, |
| 656 | canonical: str, |
| 657 | git: GitConfig, |
| 658 | muse: MuseConfig, |
| 659 | path: str, |
| 660 | ) -> None: |
| 661 | if regime == "git-only": |
| 662 | if canonical != "git": |
| 663 | raise ConfigError("git-only regime requires vcs.canonical: git", path) |
| 664 | if muse.staging_remote is not None or muse.main_branch is not None: |
| 665 | raise ConfigError("git-only regime requires muse fields to be null", path) |
| 666 | return |
| 667 | |
| 668 | if regime == "muse-only": |
| 669 | if canonical != "muse": |
| 670 | raise ConfigError("muse-only regime requires vcs.canonical: muse", path) |
| 671 | if not muse.main_branch: |
| 672 | raise ConfigError("muse-only regime requires vcs.muse.main_branch", path) |
| 673 | return |
| 674 | |
| 675 | if regime == "muse+git-mirror": |
| 676 | if canonical != "muse": |
| 677 | raise ConfigError("muse+git-mirror regime requires vcs.canonical: muse", path) |
| 678 | if not muse.main_branch: |
| 679 | raise ConfigError("muse+git-mirror regime requires vcs.muse.main_branch", path) |
| 680 | if not muse.staging_remote: |
| 681 | raise ConfigError("muse+git-mirror regime requires vcs.muse.staging_remote", path) |
| 682 | if not git.mirror_branch: |
| 683 | raise ConfigError("muse+git-mirror regime requires vcs.git.mirror_branch", path) |
| 684 | |
| 685 | |
| 686 | def _validate_repo_relative_path(value: str, field: str, path: str) -> None: |
| 687 | """Reject absolute paths and ``..`` segments in config path fields.""" |
| 688 | text = value.strip() |
| 689 | if not text: |
| 690 | raise ConfigError(f"{field} must be a non-empty string", path) |
| 691 | candidate = Path(text) |
| 692 | if candidate.is_absolute(): |
| 693 | raise ConfigError(f"{field} must be repo-relative", path) |
| 694 | if ".." in candidate.parts: |
| 695 | raise ConfigError(f"{field} must not contain '..' path segments", path) |
| 696 | |
| 697 | |
| 698 | def _parse_k9_modules( |
| 699 | raw: dict[str, Any], |
| 700 | path: str, |
| 701 | ) -> tuple[CheckpointsConfig, HonestyConfig, ModulesConfig | None, tuple[ExtensionEntry, ...], tuple[str, ...]]: |
| 702 | """Parse optional K9 checkpoints/honesty/modules/extensions (§K9.2).""" |
| 703 | checkpoints = _parse_checkpoints(raw.get("checkpoints"), path) |
| 704 | honesty = _parse_honesty(raw.get("honesty"), path) |
| 705 | modules = _parse_modules(raw.get("modules"), path) |
| 706 | extensions, extension_warnings = _parse_extensions(raw.get("extensions"), path) |
| 707 | |
| 708 | if modules is not None: |
| 709 | if modules.governance_enabled is False: |
| 710 | raise ConfigError("modules.governance.enabled cannot be false", path) |
| 711 | if modules.checkpoints_enabled is not None and modules.checkpoints_enabled != checkpoints.enabled: |
| 712 | raise ConfigError( |
| 713 | "modules.checkpoints.enabled must equal checkpoints.enabled", |
| 714 | path, |
| 715 | ) |
| 716 | if modules.honesty_enabled is not None and modules.honesty_enabled != honesty.enabled: |
| 717 | raise ConfigError( |
| 718 | "modules.honesty.enabled must equal honesty.enabled", |
| 719 | path, |
| 720 | ) |
| 721 | |
| 722 | return checkpoints, honesty, modules, extensions, extension_warnings |
| 723 | |
| 724 | |
| 725 | def _parse_checkpoints(raw_checkpoints: Any, path: str) -> CheckpointsConfig: |
| 726 | if raw_checkpoints is None: |
| 727 | return CheckpointsConfig() |
| 728 | cp_raw = _require_mapping(raw_checkpoints, "checkpoints", path) |
| 729 | extra = set(cp_raw) - CHECKPOINTS_KEYS |
| 730 | if extra: |
| 731 | raise ConfigError(f"unknown checkpoints keys: {sorted(extra)}", path) |
| 732 | |
| 733 | enabled = cp_raw.get("enabled", False) |
| 734 | if not isinstance(enabled, bool): |
| 735 | raise ConfigError("checkpoints.enabled must be a boolean", path) |
| 736 | |
| 737 | policy = _optional_str(cp_raw, "policy") |
| 738 | if policy is not None: |
| 739 | _validate_repo_relative_path(policy, "checkpoints.policy", path) |
| 740 | active_manifest = _optional_str(cp_raw, "active_manifest") |
| 741 | if active_manifest is not None: |
| 742 | _validate_repo_relative_path(active_manifest, "checkpoints.active_manifest", path) |
| 743 | progress = _optional_str(cp_raw, "progress") |
| 744 | if progress is not None: |
| 745 | _validate_repo_relative_path(progress, "checkpoints.progress", path) |
| 746 | orchestrator = _optional_str(cp_raw, "orchestrator") |
| 747 | if orchestrator is not None: |
| 748 | _validate_repo_relative_path(orchestrator, "checkpoints.orchestrator", path) |
| 749 | |
| 750 | allow_hand = cp_raw.get("allow_hand_verified", False) |
| 751 | if not isinstance(allow_hand, bool): |
| 752 | raise ConfigError("checkpoints.allow_hand_verified must be a boolean", path) |
| 753 | if allow_hand: |
| 754 | raise ConfigError("checkpoints.allow_hand_verified is forbidden", path) |
| 755 | |
| 756 | if enabled and (policy is None or not policy.strip()): |
| 757 | raise ConfigError("checkpoints.enabled requires non-empty checkpoints.policy", path) |
| 758 | |
| 759 | return CheckpointsConfig( |
| 760 | enabled=enabled, |
| 761 | policy=policy, |
| 762 | active_manifest=active_manifest, |
| 763 | progress=progress, |
| 764 | orchestrator=orchestrator, |
| 765 | allow_hand_verified=allow_hand, |
| 766 | ) |
| 767 | |
| 768 | |
| 769 | def _parse_honesty(raw_honesty: Any, path: str) -> HonestyConfig: |
| 770 | if raw_honesty is None: |
| 771 | return HonestyConfig() |
| 772 | h_raw = _require_mapping(raw_honesty, "honesty", path) |
| 773 | extra = set(h_raw) - HONESTY_KEYS |
| 774 | if extra: |
| 775 | raise ConfigError(f"unknown honesty keys: {sorted(extra)}", path) |
| 776 | |
| 777 | enabled = h_raw.get("enabled", False) |
| 778 | if not isinstance(enabled, bool): |
| 779 | raise ConfigError("honesty.enabled must be a boolean", path) |
| 780 | |
| 781 | ledger = _optional_str(h_raw, "ledger") |
| 782 | if ledger is not None: |
| 783 | _validate_repo_relative_path(ledger, "honesty.ledger", path) |
| 784 | roles_file = _optional_str(h_raw, "roles_file") |
| 785 | if roles_file is not None: |
| 786 | _validate_repo_relative_path(roles_file, "honesty.roles_file", path) |
| 787 | ci_reexecutor = _optional_str(h_raw, "ci_reexecutor") |
| 788 | if ci_reexecutor is not None: |
| 789 | _validate_repo_relative_path(ci_reexecutor, "honesty.ci_reexecutor", path) |
| 790 | |
| 791 | require_l1 = h_raw.get("require_l1_evidence", "warn") |
| 792 | if not isinstance(require_l1, str) or require_l1 not in L1_EVIDENCE_MODES: |
| 793 | raise ConfigError("honesty.require_l1_evidence must be off|warn|require", path) |
| 794 | |
| 795 | require_verification = h_raw.get("require_verification_evidence", "off") |
| 796 | if not isinstance(require_verification, str) or require_verification not in L1_EVIDENCE_MODES: |
| 797 | raise ConfigError("honesty.require_verification_evidence must be off|warn|require", path) |
| 798 | |
| 799 | require_deploy_health = h_raw.get("require_deploy_health", "off") |
| 800 | if not isinstance(require_deploy_health, str) or require_deploy_health not in L1_EVIDENCE_MODES: |
| 801 | raise ConfigError("honesty.require_deploy_health must be off|warn|require", path) |
| 802 | |
| 803 | require_isr = h_raw.get("require_independent_second_reviewer", "require") |
| 804 | if not isinstance(require_isr, str) or require_isr not in L1_EVIDENCE_MODES: |
| 805 | raise ConfigError( |
| 806 | "honesty.require_independent_second_reviewer must be off|warn|require", path |
| 807 | ) |
| 808 | |
| 809 | allow_signed = h_raw.get("allow_signed_approval", False) |
| 810 | if not isinstance(allow_signed, bool): |
| 811 | raise ConfigError("honesty.allow_signed_approval must be a boolean", path) |
| 812 | |
| 813 | require_agent_signature = h_raw.get("require_agent_signature", False) |
| 814 | if not isinstance(require_agent_signature, bool): |
| 815 | raise ConfigError("honesty.require_agent_signature must be a boolean", path) |
| 816 | |
| 817 | require_verdict_on = _parse_require_verdict_on(h_raw.get("require_verdict_on"), path) |
| 818 | |
| 819 | if enabled and (ledger is None or not ledger.strip()): |
| 820 | raise ConfigError("honesty.enabled requires non-empty honesty.ledger", path) |
| 821 | |
| 822 | return HonestyConfig( |
| 823 | enabled=enabled, |
| 824 | ledger=ledger, |
| 825 | roles_file=roles_file, |
| 826 | require_verdict_on=require_verdict_on, |
| 827 | require_l1_evidence=require_l1, |
| 828 | require_verification_evidence=require_verification, |
| 829 | require_deploy_health=require_deploy_health, |
| 830 | require_independent_second_reviewer=require_isr, |
| 831 | allow_signed_approval=allow_signed, |
| 832 | ci_reexecutor=ci_reexecutor, |
| 833 | require_agent_signature=require_agent_signature, |
| 834 | ) |
| 835 | |
| 836 | |
| 837 | def _parse_require_verdict_on(raw_value: Any, path: str) -> frozenset[str]: |
| 838 | if raw_value is None: |
| 839 | return frozenset(HOOK_NAMES) |
| 840 | if not isinstance(raw_value, list): |
| 841 | raise ConfigError("honesty.require_verdict_on must be a list or null", path) |
| 842 | if not raw_value: |
| 843 | raise ConfigError("honesty.require_verdict_on must not be empty", path) |
| 844 | hooks: set[str] = set() |
| 845 | for item in raw_value: |
| 846 | if not isinstance(item, str) or item not in HOOK_NAMES: |
| 847 | raise ConfigError( |
| 848 | "honesty.require_verdict_on entries must be board_done|handoff|register", |
| 849 | path, |
| 850 | ) |
| 851 | hooks.add(item) |
| 852 | return frozenset(hooks) |
| 853 | |
| 854 | |
| 855 | def _parse_modules(raw_modules: Any, path: str) -> ModulesConfig | None: |
| 856 | if raw_modules is None: |
| 857 | return None |
| 858 | m_raw = _require_mapping(raw_modules, "modules", path) |
| 859 | allowed = {"governance", "checkpoints", "honesty"} |
| 860 | extra = set(m_raw) - allowed |
| 861 | if extra: |
| 862 | raise ConfigError(f"unknown modules keys: {sorted(extra)}", path) |
| 863 | |
| 864 | governance_enabled = True |
| 865 | if "governance" in m_raw: |
| 866 | gov_raw = _require_mapping(m_raw["governance"], "modules.governance", path) |
| 867 | gov_extra = set(gov_raw) - MODULES_GOVERNANCE_KEYS |
| 868 | if gov_extra: |
| 869 | raise ConfigError(f"unknown modules.governance keys: {sorted(gov_extra)}", path) |
| 870 | gov_enabled = gov_raw.get("enabled", True) |
| 871 | if not isinstance(gov_enabled, bool): |
| 872 | raise ConfigError("modules.governance.enabled must be a boolean", path) |
| 873 | governance_enabled = gov_enabled |
| 874 | |
| 875 | checkpoints_enabled: bool | None = None |
| 876 | if "checkpoints" in m_raw: |
| 877 | cp_raw = _require_mapping(m_raw["checkpoints"], "modules.checkpoints", path) |
| 878 | cp_extra = set(cp_raw) - MODULES_CHECKPOINTS_KEYS |
| 879 | if cp_extra: |
| 880 | raise ConfigError(f"unknown modules.checkpoints keys: {sorted(cp_extra)}", path) |
| 881 | value = cp_raw.get("enabled") |
| 882 | if not isinstance(value, bool): |
| 883 | raise ConfigError("modules.checkpoints.enabled must be a boolean", path) |
| 884 | checkpoints_enabled = value |
| 885 | |
| 886 | honesty_enabled: bool | None = None |
| 887 | if "honesty" in m_raw: |
| 888 | h_raw = _require_mapping(m_raw["honesty"], "modules.honesty", path) |
| 889 | h_extra = set(h_raw) - MODULES_HONESTY_KEYS |
| 890 | if h_extra: |
| 891 | raise ConfigError(f"unknown modules.honesty keys: {sorted(h_extra)}", path) |
| 892 | value = h_raw.get("enabled") |
| 893 | if not isinstance(value, bool): |
| 894 | raise ConfigError("modules.honesty.enabled must be a boolean", path) |
| 895 | honesty_enabled = value |
| 896 | |
| 897 | return ModulesConfig( |
| 898 | governance_enabled=governance_enabled, |
| 899 | checkpoints_enabled=checkpoints_enabled, |
| 900 | honesty_enabled=honesty_enabled, |
| 901 | ) |
| 902 | |
| 903 | |
| 904 | def _parse_post_land_sync(raw_sync: Any, path: str) -> PostLandSyncConfig: |
| 905 | """Parse optional ``close_ritual.post_land_sync`` (§PLS.3.2 — fail-closed).""" |
| 906 | if raw_sync is None: |
| 907 | return PostLandSyncConfig() |
| 908 | sync_raw = _require_mapping(raw_sync, "close_ritual.post_land_sync", path) |
| 909 | allowed = {"enabled", "strategy", "require_clean_worktree"} |
| 910 | extra = set(sync_raw) - allowed |
| 911 | if extra: |
| 912 | raise ConfigError(f"unknown close_ritual.post_land_sync keys: {sorted(extra)}", path) |
| 913 | |
| 914 | enabled = sync_raw.get("enabled", False) |
| 915 | if not isinstance(enabled, bool): |
| 916 | raise ConfigError("close_ritual.post_land_sync.enabled must be a boolean", path) |
| 917 | |
| 918 | strategy = sync_raw.get("strategy", "ff_only") |
| 919 | if strategy != "ff_only": |
| 920 | raise ConfigError( |
| 921 | "close_ritual.post_land_sync.strategy must be ff_only (v1 closed vocabulary)", |
| 922 | path, |
| 923 | ) |
| 924 | |
| 925 | require_clean = sync_raw.get("require_clean_worktree", True) |
| 926 | if not isinstance(require_clean, bool) or require_clean is not True: |
| 927 | raise ConfigError( |
| 928 | "close_ritual.post_land_sync.require_clean_worktree must be true in v1 " |
| 929 | "(dirty-tree merges are a non-goal)", |
| 930 | path, |
| 931 | ) |
| 932 | |
| 933 | return PostLandSyncConfig( |
| 934 | enabled=enabled, |
| 935 | strategy="ff_only", |
| 936 | require_clean_worktree=True, |
| 937 | ) |
| 938 | |
| 939 | |
| 940 | def _parse_close_ritual(raw_ritual: Any, path: str) -> CloseRitualConfig: |
| 941 | """Parse optional ``close_ritual`` section (land-to-main; never auto-merge).""" |
| 942 | if raw_ritual is None: |
| 943 | return CloseRitualConfig() |
| 944 | ritual_raw = _require_mapping(raw_ritual, "close_ritual", path) |
| 945 | allowed = {"enabled", "mode", "require_paths", "consumer_verify_script", "post_land_sync"} |
| 946 | extra = set(ritual_raw) - allowed |
| 947 | if extra: |
| 948 | raise ConfigError(f"unknown close_ritual keys: {sorted(extra)}", path) |
| 949 | |
| 950 | enabled = ritual_raw.get("enabled", False) |
| 951 | if not isinstance(enabled, bool): |
| 952 | raise ConfigError("close_ritual.enabled must be a boolean", path) |
| 953 | |
| 954 | mode = ritual_raw.get("mode", "verify_landed") |
| 955 | if mode not in {"verify_landed", "prepare_pr"}: |
| 956 | raise ConfigError( |
| 957 | "close_ritual.mode must be verify_landed|prepare_pr", |
| 958 | path, |
| 959 | ) |
| 960 | |
| 961 | paths_raw = ritual_raw.get("require_paths", []) |
| 962 | if not isinstance(paths_raw, list) or not all(isinstance(p, str) and p.strip() for p in paths_raw): |
| 963 | raise ConfigError("close_ritual.require_paths must be a list of non-empty strings", path) |
| 964 | for p in paths_raw: |
| 965 | candidate = Path(p) |
| 966 | if candidate.is_absolute() or ".." in candidate.parts: |
| 967 | raise ConfigError( |
| 968 | "close_ritual.require_paths entries must be relative without '..'", |
| 969 | path, |
| 970 | ) |
| 971 | |
| 972 | script = _optional_str(ritual_raw, "consumer_verify_script") |
| 973 | if script is not None: |
| 974 | script_path = Path(script) |
| 975 | if script_path.is_absolute() or ".." in script_path.parts: |
| 976 | raise ConfigError( |
| 977 | "close_ritual.consumer_verify_script must be relative without '..'", |
| 978 | path, |
| 979 | ) |
| 980 | |
| 981 | return CloseRitualConfig( |
| 982 | enabled=enabled, |
| 983 | mode=str(mode), |
| 984 | require_paths=tuple(str(p).strip() for p in paths_raw), |
| 985 | consumer_verify_script=script, |
| 986 | post_land_sync=_parse_post_land_sync(ritual_raw.get("post_land_sync"), path), |
| 987 | ) |
| 988 | |
| 989 | |
| 990 | def _parse_governance_gates(raw_gates: Any, path: str) -> GovernanceGatesConfig: |
| 991 | """Parse optional ``governance_gates`` section (§KH1.9).""" |
| 992 | if raw_gates is None: |
| 993 | return GovernanceGatesConfig() |
| 994 | gates_raw = _require_mapping(raw_gates, "governance_gates", path) |
| 995 | extra = set(gates_raw) - GOVERNANCE_GATES_KEYS |
| 996 | if extra: |
| 997 | raise ConfigError(f"unknown governance_gates keys: {sorted(extra)}", path) |
| 998 | |
| 999 | remind = gates_raw.get("remind", True) |
| 1000 | if not isinstance(remind, bool): |
| 1001 | raise ConfigError("governance_gates.remind must be a boolean", path) |
| 1002 | |
| 1003 | freeze_required = True |
| 1004 | if "freeze_review" in gates_raw: |
| 1005 | freeze_raw = _require_mapping(gates_raw["freeze_review"], "governance_gates.freeze_review", path) |
| 1006 | freeze_extra = set(freeze_raw) - {"required_before_auto"} |
| 1007 | if freeze_extra: |
| 1008 | raise ConfigError( |
| 1009 | f"unknown governance_gates.freeze_review keys: {sorted(freeze_extra)}", |
| 1010 | path, |
| 1011 | ) |
| 1012 | value = freeze_raw.get("required_before_auto", True) |
| 1013 | if not isinstance(value, bool): |
| 1014 | raise ConfigError( |
| 1015 | "governance_gates.freeze_review.required_before_auto must be a boolean", |
| 1016 | path, |
| 1017 | ) |
| 1018 | freeze_required = value |
| 1019 | |
| 1020 | build_required = True |
| 1021 | if "build_verification" in gates_raw: |
| 1022 | build_raw = _require_mapping( |
| 1023 | gates_raw["build_verification"], |
| 1024 | "governance_gates.build_verification", |
| 1025 | path, |
| 1026 | ) |
| 1027 | build_extra = set(build_raw) - {"required_before_done"} |
| 1028 | if build_extra: |
| 1029 | raise ConfigError( |
| 1030 | f"unknown governance_gates.build_verification keys: {sorted(build_extra)}", |
| 1031 | path, |
| 1032 | ) |
| 1033 | value = build_raw.get("required_before_done", True) |
| 1034 | if not isinstance(value, bool): |
| 1035 | raise ConfigError( |
| 1036 | "governance_gates.build_verification.required_before_done must be a boolean", |
| 1037 | path, |
| 1038 | ) |
| 1039 | build_required = value |
| 1040 | |
| 1041 | surfaces: frozenset[str] = frozenset(GOVERNANCE_GATES_SURFACES) |
| 1042 | if "surfaces" in gates_raw: |
| 1043 | raw_surfaces = gates_raw["surfaces"] |
| 1044 | if not isinstance(raw_surfaces, list) or not raw_surfaces: |
| 1045 | raise ConfigError("governance_gates.surfaces must be a non-empty list", path) |
| 1046 | parsed: set[str] = set() |
| 1047 | for item in raw_surfaces: |
| 1048 | if not isinstance(item, str) or item not in GOVERNANCE_GATES_SURFACES: |
| 1049 | raise ConfigError( |
| 1050 | "governance_gates.surfaces entries must be status|governance-sync|handover-paste", |
| 1051 | path, |
| 1052 | ) |
| 1053 | parsed.add(item) |
| 1054 | surfaces = frozenset(parsed) |
| 1055 | |
| 1056 | return GovernanceGatesConfig( |
| 1057 | remind=remind, |
| 1058 | freeze_review_required=freeze_required, |
| 1059 | build_verification_required=build_required, |
| 1060 | surfaces=surfaces, |
| 1061 | ) |
| 1062 | |
| 1063 | |
| 1064 | def _parse_model_routing(raw_routing: Any, path: str) -> ModelRoutingConfig: |
| 1065 | """Parse optional ``model_routing`` section (§PR.5).""" |
| 1066 | if raw_routing is None: |
| 1067 | return ModelRoutingConfig() |
| 1068 | routing_raw = _require_mapping(raw_routing, "model_routing", path) |
| 1069 | extra = set(routing_raw) - MODEL_ROUTING_KEYS |
| 1070 | if extra: |
| 1071 | raise ConfigError(f"unknown model_routing keys: {sorted(extra)}", path) |
| 1072 | |
| 1073 | enabled = routing_raw.get("enabled", False) |
| 1074 | if not isinstance(enabled, bool): |
| 1075 | raise ConfigError("model_routing.enabled must be a boolean", path) |
| 1076 | |
| 1077 | policy = routing_raw.get("policy", DEFAULT_MODEL_ROUTING_POLICY) |
| 1078 | if not isinstance(policy, str) or not policy.strip(): |
| 1079 | raise ConfigError("model_routing.policy must be a non-empty string", path) |
| 1080 | _validate_repo_relative_path(policy, "model_routing.policy", path) |
| 1081 | |
| 1082 | return ModelRoutingConfig(enabled=enabled, policy=policy.strip()) |
| 1083 | |
| 1084 | |
| 1085 | def _parse_hosted_dashboard(raw_block: Any, path: str) -> HostedDashboardSection: |
| 1086 | """Parse optional ``hosted_dashboard`` section (§HGD.10.2); unknown keys fail closed.""" |
| 1087 | if raw_block is None: |
| 1088 | return HostedDashboardSection() |
| 1089 | try: |
| 1090 | from tools.hosted_dashboard.config import parse_hosted_dashboard_config |
| 1091 | |
| 1092 | parsed = parse_hosted_dashboard_config(raw_block, path=path) |
| 1093 | except Exception as exc: |
| 1094 | raise ConfigError(str(exc), path) from exc |
| 1095 | return HostedDashboardSection( |
| 1096 | enabled=parsed.enabled, |
| 1097 | allow_non_loopback=parsed.allow_non_loopback, |
| 1098 | cors_origins=parsed.cors_origins, |
| 1099 | org_allowlist=parsed.org_allowlist, |
| 1100 | github_contents=parsed.sources.github_contents, |
| 1101 | github_meta=parsed.sources.github_meta, |
| 1102 | github_checks_advisory=parsed.sources.github_checks_advisory, |
| 1103 | musehub_read=parsed.sources.musehub_read, |
| 1104 | ) |
| 1105 | |
| 1106 | |
| 1107 | def _parse_cost_awareness(raw_cost: Any, path: str) -> CostAwarenessConfig: |
| 1108 | """Parse optional ``cost_awareness`` section (§PC.6).""" |
| 1109 | if raw_cost is None: |
| 1110 | return CostAwarenessConfig() |
| 1111 | cost_raw = _require_mapping(raw_cost, "cost_awareness", path) |
| 1112 | extra = set(cost_raw) - COST_AWARENESS_KEYS |
| 1113 | if extra: |
| 1114 | raise ConfigError(f"unknown cost_awareness keys: {sorted(extra)}", path) |
| 1115 | |
| 1116 | enabled = cost_raw.get("enabled", False) |
| 1117 | if not isinstance(enabled, bool): |
| 1118 | raise ConfigError("cost_awareness.enabled must be a boolean", path) |
| 1119 | |
| 1120 | surfaces: frozenset[str] = frozenset(COST_AWARENESS_SURFACES) |
| 1121 | if "surfaces" in cost_raw: |
| 1122 | raw_surfaces = cost_raw["surfaces"] |
| 1123 | if not isinstance(raw_surfaces, list) or not raw_surfaces: |
| 1124 | raise ConfigError("cost_awareness.surfaces must be a non-empty list", path) |
| 1125 | parsed: set[str] = set() |
| 1126 | for item in raw_surfaces: |
| 1127 | if not isinstance(item, str) or item not in COST_AWARENESS_SURFACES: |
| 1128 | raise ConfigError( |
| 1129 | "cost_awareness.surfaces entries must be status|governance-sync", |
| 1130 | path, |
| 1131 | ) |
| 1132 | parsed.add(item) |
| 1133 | surfaces = frozenset(parsed) |
| 1134 | |
| 1135 | return CostAwarenessConfig(enabled=enabled, surfaces=surfaces) |
| 1136 | |
| 1137 | |
| 1138 | def _parse_session_bookends(raw_bookends: Any, path: str) -> SessionBookendsConfig: |
| 1139 | """Parse optional ``session_bookends`` section (§LT.4.2).""" |
| 1140 | if raw_bookends is None: |
| 1141 | return SessionBookendsConfig() |
| 1142 | bookends_raw = _require_mapping(raw_bookends, "session_bookends", path) |
| 1143 | extra = set(bookends_raw) - SESSION_BOOKENDS_KEYS |
| 1144 | if extra: |
| 1145 | raise ConfigError(f"unknown session_bookends keys: {sorted(extra)}", path) |
| 1146 | |
| 1147 | enabled = bookends_raw.get("enabled", False) |
| 1148 | if not isinstance(enabled, bool): |
| 1149 | raise ConfigError("session_bookends.enabled must be a boolean", path) |
| 1150 | |
| 1151 | return SessionBookendsConfig(enabled=enabled) |
| 1152 | |
| 1153 | |
| 1154 | def _parse_workspace(raw_workspace: Any, path: str) -> WorkspacePointerConfig | None: |
| 1155 | """Parse optional additive ``workspace:`` pointer (§MR.4.2).""" |
| 1156 | if raw_workspace is None: |
| 1157 | return None |
| 1158 | ws_raw = _require_mapping(raw_workspace, "workspace", path) |
| 1159 | extra = set(ws_raw) - WORKSPACE_KEYS |
| 1160 | if extra: |
| 1161 | raise ConfigError(f"unknown workspace keys: {sorted(extra)}", path) |
| 1162 | constellation_id = _require_str(ws_raw, "constellation_id", path) |
| 1163 | product_order_root = _optional_str(ws_raw, "product_order_root") |
| 1164 | manifest = _optional_str(ws_raw, "manifest") |
| 1165 | return WorkspacePointerConfig( |
| 1166 | constellation_id=constellation_id, |
| 1167 | product_order_root=product_order_root, |
| 1168 | manifest=manifest, |
| 1169 | ) |
| 1170 | |
| 1171 | |
| 1172 | def _parse_extensions( |
| 1173 | raw_extensions: Any, |
| 1174 | path: str, |
| 1175 | ) -> tuple[tuple[ExtensionEntry, ...], tuple[str, ...]]: |
| 1176 | if raw_extensions is None: |
| 1177 | return (), () |
| 1178 | if not isinstance(raw_extensions, list): |
| 1179 | raise ConfigError("extensions must be a list", path) |
| 1180 | |
| 1181 | entries: list[ExtensionEntry] = [] |
| 1182 | warnings: list[str] = [] |
| 1183 | for index, item in enumerate(raw_extensions): |
| 1184 | prefix = f"extensions[{index}]" |
| 1185 | if not isinstance(item, dict): |
| 1186 | raise ConfigError(f"{prefix} must be a mapping", path) |
| 1187 | extra = set(item) - EXTENSION_KEYS |
| 1188 | if extra: |
| 1189 | raise ConfigError(f"unknown {prefix} keys: {sorted(extra)}", path) |
| 1190 | ext_id = item.get("id") |
| 1191 | if not isinstance(ext_id, str) or not ext_id.strip(): |
| 1192 | raise ConfigError(f"{prefix}.id must be a non-empty string", path) |
| 1193 | schema_version = item.get("schema_version") |
| 1194 | if not isinstance(schema_version, int): |
| 1195 | raise ConfigError(f"{prefix}.schema_version must be an integer", path) |
| 1196 | config_path = item.get("config_path") |
| 1197 | if not isinstance(config_path, str) or not config_path.strip(): |
| 1198 | raise ConfigError(f"{prefix}.config_path must be a non-empty string", path) |
| 1199 | _validate_repo_relative_path(config_path, f"{prefix}.config_path", path) |
| 1200 | entries.append( |
| 1201 | ExtensionEntry(id=ext_id.strip(), schema_version=schema_version, config_path=config_path) |
| 1202 | ) |
| 1203 | warnings.append( |
| 1204 | f"extensions[{index}] id={ext_id!r} schema_version={schema_version} ignored (v1 registry empty)" |
| 1205 | ) |
| 1206 | return tuple(entries), tuple(warnings) |
File History
2 commits
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199
fix(ISR): default require_independent_second_reviewer to require
Human
minor
⚠
5 hours ago
sha256:d19ce6f1b9af7123f2952c16142303cf906a8141fca5d75f6824d4312dccbfe9
chore(governance): sync handover+roadmap to fc2ecb0 (drift:…
Human
5 hours ago