config.py
python
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439
feat: K1-P1 complete — agent provenance, build-verification…
Sonnet 4.6
patch
54 days 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 | "allow_signed_approval", |
| 42 | "ci_reexecutor", |
| 43 | "require_agent_signature", |
| 44 | } |
| 45 | ) |
| 46 | MODULES_GOVERNANCE_KEYS = frozenset({"enabled"}) |
| 47 | MODULES_CHECKPOINTS_KEYS = frozenset({"enabled"}) |
| 48 | MODULES_HONESTY_KEYS = frozenset({"enabled"}) |
| 49 | EXTENSION_KEYS = frozenset({"id", "schema_version", "config_path"}) |
| 50 | HOOK_NAMES = frozenset({"board_done", "handoff", "register"}) |
| 51 | GOVERNANCE_GATES_SURFACES = frozenset({"status", "governance-sync", "handover-paste"}) |
| 52 | GOVERNANCE_GATES_KEYS = frozenset( |
| 53 | { |
| 54 | "remind", |
| 55 | "freeze_review", |
| 56 | "build_verification", |
| 57 | "surfaces", |
| 58 | } |
| 59 | ) |
| 60 | L1_EVIDENCE_MODES = frozenset({"off", "warn", "require"}) |
| 61 | |
| 62 | |
| 63 | @dataclass(frozen=True) |
| 64 | class GitConfig: |
| 65 | remote: str |
| 66 | main_branch: str |
| 67 | mirror_branch: str | None |
| 68 | feature_branch_pattern: str |
| 69 | |
| 70 | |
| 71 | @dataclass(frozen=True) |
| 72 | class MuseConfig: |
| 73 | staging_remote: str | None |
| 74 | main_branch: str | None |
| 75 | working_dir: str | None = None |
| 76 | |
| 77 | |
| 78 | @dataclass(frozen=True) |
| 79 | class LaneDocsConfig: |
| 80 | """One handover + roadmap pair (K8 multi-lane).""" |
| 81 | |
| 82 | handover: str |
| 83 | roadmap: str |
| 84 | handover_title: str |
| 85 | roadmap_title: str |
| 86 | |
| 87 | |
| 88 | @dataclass(frozen=True) |
| 89 | class DocsConfig: |
| 90 | handover: str |
| 91 | roadmap: str |
| 92 | coordination: str | None |
| 93 | standing_decisions: str |
| 94 | handover_title: str |
| 95 | roadmap_title: str |
| 96 | default_lane: str | None = None |
| 97 | lanes: dict[str, LaneDocsConfig] | None = None |
| 98 | |
| 99 | |
| 100 | @dataclass(frozen=True) |
| 101 | class ThresholdsConfig: |
| 102 | realign_max_commits: int |
| 103 | drift_warn_only: bool |
| 104 | |
| 105 | |
| 106 | @dataclass(frozen=True) |
| 107 | class ReviewerConfig: |
| 108 | mode: str |
| 109 | model: str |
| 110 | provider: str |
| 111 | fallback: str |
| 112 | |
| 113 | |
| 114 | @dataclass(frozen=True) |
| 115 | class FreezeContractConfig: |
| 116 | enabled: bool |
| 117 | reviewer: ReviewerConfig |
| 118 | human_escalation: list[str] |
| 119 | |
| 120 | |
| 121 | @dataclass(frozen=True) |
| 122 | class RepoConfig: |
| 123 | name: str |
| 124 | root_relative_docs: str |
| 125 | |
| 126 | |
| 127 | @dataclass(frozen=True) |
| 128 | class VcsConfig: |
| 129 | regime: str |
| 130 | canonical: str |
| 131 | git: GitConfig |
| 132 | muse: MuseConfig |
| 133 | |
| 134 | |
| 135 | @dataclass(frozen=True) |
| 136 | class CheckpointsConfig: |
| 137 | """L1 checkpoint module settings (§K9.2).""" |
| 138 | |
| 139 | enabled: bool = False |
| 140 | policy: str | None = None |
| 141 | active_manifest: str | None = None |
| 142 | progress: str | None = None |
| 143 | orchestrator: str | None = None |
| 144 | allow_hand_verified: bool = False |
| 145 | |
| 146 | |
| 147 | @dataclass(frozen=True) |
| 148 | class HonestyConfig: |
| 149 | """L2 honesty module settings (§K9.2) — parsed for forward compat; K10 builds CLI.""" |
| 150 | |
| 151 | enabled: bool = False |
| 152 | ledger: str | None = None |
| 153 | roles_file: str | None = None |
| 154 | require_verdict_on: frozenset[str] = frozenset({"board_done", "handoff", "register"}) |
| 155 | require_l1_evidence: str = "warn" |
| 156 | allow_signed_approval: bool = False |
| 157 | ci_reexecutor: str | None = None |
| 158 | require_agent_signature: bool = False |
| 159 | |
| 160 | |
| 161 | @dataclass(frozen=True) |
| 162 | class ModulesConfig: |
| 163 | """Optional mirror of section enable flags (§K9.2).""" |
| 164 | |
| 165 | governance_enabled: bool = True |
| 166 | checkpoints_enabled: bool | None = None |
| 167 | honesty_enabled: bool | None = None |
| 168 | |
| 169 | |
| 170 | @dataclass(frozen=True) |
| 171 | class GovernanceGatesConfig: |
| 172 | """Governance gate reminder settings (§KH1.9).""" |
| 173 | |
| 174 | remind: bool = True |
| 175 | freeze_review_required: bool = True |
| 176 | build_verification_required: bool = True |
| 177 | surfaces: frozenset[str] = frozenset(GOVERNANCE_GATES_SURFACES) |
| 178 | |
| 179 | |
| 180 | @dataclass(frozen=True) |
| 181 | class ExtensionEntry: |
| 182 | """One extensions[] escape-hatch entry (§K9.2).""" |
| 183 | |
| 184 | id: str |
| 185 | schema_version: int |
| 186 | config_path: str |
| 187 | |
| 188 | |
| 189 | @dataclass(frozen=True) |
| 190 | class OverseerConfig: |
| 191 | overseer_config_version: int |
| 192 | repo: RepoConfig |
| 193 | vcs: VcsConfig |
| 194 | docs: DocsConfig |
| 195 | thresholds: ThresholdsConfig |
| 196 | freeze_contract: FreezeContractConfig |
| 197 | checkpoints: CheckpointsConfig = CheckpointsConfig() |
| 198 | honesty: HonestyConfig = HonestyConfig() |
| 199 | modules: ModulesConfig | None = None |
| 200 | extensions: tuple[ExtensionEntry, ...] = () |
| 201 | extension_warnings: tuple[str, ...] = () |
| 202 | governance_gates: GovernanceGatesConfig = GovernanceGatesConfig() |
| 203 | |
| 204 | |
| 205 | def load_config(path: Path) -> OverseerConfig: |
| 206 | """Parse and validate config; raise ``ConfigError`` on any violation.""" |
| 207 | path = path.resolve() |
| 208 | if not path.is_file(): |
| 209 | raise ConfigError("config file missing", str(path)) |
| 210 | |
| 211 | try: |
| 212 | raw_text = path.read_text(encoding="utf-8") |
| 213 | except OSError as exc: |
| 214 | raise ConfigError(f"cannot read config: {exc}", str(path)) from exc |
| 215 | |
| 216 | try: |
| 217 | raw = yaml.safe_load(raw_text) |
| 218 | except yaml.YAMLError as exc: |
| 219 | raise ConfigError(f"unparseable YAML: {exc}", str(path)) from exc |
| 220 | |
| 221 | if not isinstance(raw, dict): |
| 222 | raise ConfigError("config root must be a mapping", str(path)) |
| 223 | |
| 224 | return _validate_config(raw, str(path)) |
| 225 | |
| 226 | |
| 227 | def _require_mapping(data: Any, field: str, path: str) -> dict[str, Any]: |
| 228 | if not isinstance(data, dict): |
| 229 | raise ConfigError(f"{field} must be a mapping", path) |
| 230 | return data |
| 231 | |
| 232 | |
| 233 | def _require_str(data: dict[str, Any], field: str, path: str) -> str: |
| 234 | value = data.get(field) |
| 235 | if not isinstance(value, str) or not value.strip(): |
| 236 | raise ConfigError(f"{field} must be a non-empty string", path) |
| 237 | return value |
| 238 | |
| 239 | |
| 240 | def _optional_str(data: dict[str, Any], field: str) -> str | None: |
| 241 | value = data.get(field) |
| 242 | if value is None: |
| 243 | return None |
| 244 | if not isinstance(value, str): |
| 245 | raise ConfigError(f"{field} must be a string or null") |
| 246 | return value |
| 247 | |
| 248 | |
| 249 | def _validate_config(raw: dict[str, Any], path: str) -> OverseerConfig: |
| 250 | version = raw.get("overseer_config_version") |
| 251 | if not isinstance(version, int): |
| 252 | raise ConfigError("overseer_config_version must be an integer", path) |
| 253 | if version != SUPPORTED_CONFIG_VERSION: |
| 254 | raise ConfigError( |
| 255 | f"unsupported overseer_config_version {version} " |
| 256 | f"(supported: {SUPPORTED_CONFIG_VERSION})", |
| 257 | path, |
| 258 | ) |
| 259 | |
| 260 | repo_raw = _require_mapping(raw.get("repo"), "repo", path) |
| 261 | repo = RepoConfig( |
| 262 | name=_require_str(repo_raw, "name", path), |
| 263 | root_relative_docs=_require_str(repo_raw, "root_relative_docs", path), |
| 264 | ) |
| 265 | |
| 266 | vcs_raw = _require_mapping(raw.get("vcs"), "vcs", path) |
| 267 | regime = _require_str(vcs_raw, "regime", path) |
| 268 | if regime not in SUPPORTED_REGIMES: |
| 269 | raise ConfigError( |
| 270 | f"unsupported vcs.regime {regime!r} (supported: {sorted(SUPPORTED_REGIMES)})", |
| 271 | path, |
| 272 | ) |
| 273 | |
| 274 | canonical = _require_str(vcs_raw, "canonical", path) |
| 275 | if canonical not in SUPPORTED_CANONICAL: |
| 276 | raise ConfigError(f"unsupported vcs.canonical {canonical!r}", path) |
| 277 | |
| 278 | git_raw = _require_mapping(vcs_raw.get("git"), "vcs.git", path) |
| 279 | muse_raw = _require_mapping(vcs_raw.get("muse"), "vcs.muse", path) |
| 280 | |
| 281 | git = GitConfig( |
| 282 | remote=_require_str(git_raw, "remote", path), |
| 283 | main_branch=_require_str(git_raw, "main_branch", path), |
| 284 | mirror_branch=_optional_str(git_raw, "mirror_branch"), |
| 285 | feature_branch_pattern=_require_str(git_raw, "feature_branch_pattern", path), |
| 286 | ) |
| 287 | muse = MuseConfig( |
| 288 | staging_remote=_optional_str(muse_raw, "staging_remote"), |
| 289 | main_branch=_optional_str(muse_raw, "main_branch"), |
| 290 | working_dir=_optional_str(muse_raw, "working_dir"), |
| 291 | ) |
| 292 | _validate_muse_working_dir_shape(muse.working_dir, path) |
| 293 | |
| 294 | _validate_regime_fields(regime, canonical, git, muse, path) |
| 295 | |
| 296 | docs_raw = _require_mapping(raw.get("docs"), "docs", path) |
| 297 | lanes = _parse_lanes(docs_raw.get("lanes"), path) |
| 298 | default_lane = _optional_str(docs_raw, "default_lane") |
| 299 | handover = _require_str(docs_raw, "handover", path) |
| 300 | roadmap = _require_str(docs_raw, "roadmap", path) |
| 301 | handover_title = _optional_str(docs_raw, "handover_title") or "Overseer Handover" |
| 302 | roadmap_title = _optional_str(docs_raw, "roadmap_title") or "Roadmap" |
| 303 | _validate_lanes_config( |
| 304 | lanes=lanes, |
| 305 | default_lane=default_lane, |
| 306 | handover=handover, |
| 307 | roadmap=roadmap, |
| 308 | handover_title=handover_title, |
| 309 | roadmap_title=roadmap_title, |
| 310 | path=path, |
| 311 | ) |
| 312 | docs = DocsConfig( |
| 313 | handover=handover, |
| 314 | roadmap=roadmap, |
| 315 | coordination=_optional_str(docs_raw, "coordination"), |
| 316 | standing_decisions=_require_str(docs_raw, "standing_decisions", path), |
| 317 | handover_title=handover_title, |
| 318 | roadmap_title=roadmap_title, |
| 319 | default_lane=default_lane, |
| 320 | lanes=lanes, |
| 321 | ) |
| 322 | |
| 323 | thresholds_raw = _require_mapping(raw.get("thresholds"), "thresholds", path) |
| 324 | realign_max = thresholds_raw.get("realign_max_commits") |
| 325 | if not isinstance(realign_max, int) or realign_max < 1: |
| 326 | raise ConfigError("thresholds.realign_max_commits must be a positive integer", path) |
| 327 | drift_warn = thresholds_raw.get("drift_warn_only") |
| 328 | if not isinstance(drift_warn, bool): |
| 329 | raise ConfigError("thresholds.drift_warn_only must be a boolean", path) |
| 330 | thresholds = ThresholdsConfig( |
| 331 | realign_max_commits=realign_max, |
| 332 | drift_warn_only=drift_warn, |
| 333 | ) |
| 334 | |
| 335 | freeze_raw = _require_mapping(raw.get("freeze_contract"), "freeze_contract", path) |
| 336 | enabled = freeze_raw.get("enabled") |
| 337 | if not isinstance(enabled, bool): |
| 338 | raise ConfigError("freeze_contract.enabled must be a boolean", path) |
| 339 | reviewer = _parse_reviewer_config(freeze_raw.get("reviewer"), path) |
| 340 | escalation = freeze_raw.get("human_escalation") |
| 341 | if not isinstance(escalation, list) or not all(isinstance(x, str) for x in escalation): |
| 342 | raise ConfigError("freeze_contract.human_escalation must be a list of strings", path) |
| 343 | _validate_human_escalation(list(escalation), path) |
| 344 | |
| 345 | checkpoints, honesty, modules, extensions, extension_warnings = _parse_k9_modules(raw, path) |
| 346 | if honesty.require_agent_signature and regime == "git-only": |
| 347 | raise ConfigError( |
| 348 | "honesty.require_agent_signature is forbidden under git-only", |
| 349 | path, |
| 350 | exit_code=26, |
| 351 | ) |
| 352 | governance_gates = _parse_governance_gates(raw.get("governance_gates"), path) |
| 353 | |
| 354 | return OverseerConfig( |
| 355 | overseer_config_version=version, |
| 356 | repo=repo, |
| 357 | vcs=VcsConfig(regime=regime, canonical=canonical, git=git, muse=muse), |
| 358 | docs=docs, |
| 359 | thresholds=thresholds, |
| 360 | freeze_contract=FreezeContractConfig( |
| 361 | enabled=enabled, |
| 362 | reviewer=reviewer, |
| 363 | human_escalation=list(escalation), |
| 364 | ), |
| 365 | checkpoints=checkpoints, |
| 366 | honesty=honesty, |
| 367 | modules=modules, |
| 368 | extensions=extensions, |
| 369 | extension_warnings=extension_warnings, |
| 370 | governance_gates=governance_gates, |
| 371 | ) |
| 372 | |
| 373 | |
| 374 | def _validate_muse_working_dir_shape(working_dir: str | None, path: str) -> None: |
| 375 | """Fail closed on absolute / ``..`` working_dir values (§K6.5.1).""" |
| 376 | if working_dir is None: |
| 377 | return |
| 378 | text = working_dir.strip() |
| 379 | if not text: |
| 380 | raise ConfigError("vcs.muse.working_dir must be a non-empty string or null", path) |
| 381 | candidate = Path(text) |
| 382 | if candidate.is_absolute(): |
| 383 | raise ConfigError("vcs.muse.working_dir must be relative to the install root", path) |
| 384 | if ".." in candidate.parts: |
| 385 | raise ConfigError("vcs.muse.working_dir must not contain '..' path segments", path) |
| 386 | |
| 387 | |
| 388 | def _validate_human_escalation(tokens: list[str], path: str) -> None: |
| 389 | for token in tokens: |
| 390 | if token not in HUMAN_ESCALATION_TOKENS: |
| 391 | raise ConfigError( |
| 392 | f"unknown freeze_contract.human_escalation token {token!r}", |
| 393 | path, |
| 394 | ) |
| 395 | |
| 396 | |
| 397 | def _parse_reviewer_config(raw_reviewer: Any, path: str) -> ReviewerConfig: |
| 398 | """Parse nested reviewer mapping or legacy string (§K5.3).""" |
| 399 | if isinstance(raw_reviewer, str): |
| 400 | if raw_reviewer not in REVIEWER_MODES: |
| 401 | raise ConfigError( |
| 402 | f"freeze_contract.reviewer legacy string must be agent|human, got {raw_reviewer!r}", |
| 403 | path, |
| 404 | ) |
| 405 | return ReviewerConfig( |
| 406 | mode=raw_reviewer, |
| 407 | model=DEFAULT_REVIEWER_MODEL, |
| 408 | provider=DEFAULT_REVIEWER_PROVIDER, |
| 409 | fallback=DEFAULT_REVIEWER_FALLBACK, |
| 410 | ) |
| 411 | |
| 412 | reviewer_raw = _require_mapping(raw_reviewer, "freeze_contract.reviewer", path) |
| 413 | extra = set(reviewer_raw) - REVIEWER_MAPPING_KEYS |
| 414 | if extra: |
| 415 | raise ConfigError( |
| 416 | f"unknown freeze_contract.reviewer keys: {sorted(extra)}", |
| 417 | path, |
| 418 | ) |
| 419 | |
| 420 | mode = _require_str(reviewer_raw, "mode", path) |
| 421 | if mode not in REVIEWER_MODES: |
| 422 | raise ConfigError(f"freeze_contract.reviewer.mode must be agent|human", path) |
| 423 | |
| 424 | if mode == "agent": |
| 425 | # §K5.3: all required fields for agent mode must be present; missing → 2. |
| 426 | for field_name in ("model", "provider", "fallback"): |
| 427 | if field_name not in reviewer_raw: |
| 428 | raise ConfigError( |
| 429 | f"freeze_contract.reviewer.{field_name} is required when mode is agent", |
| 430 | path, |
| 431 | ) |
| 432 | model = reviewer_raw["model"] |
| 433 | provider = reviewer_raw["provider"] |
| 434 | fallback = reviewer_raw["fallback"] |
| 435 | if not isinstance(model, str) or not model.strip(): |
| 436 | raise ConfigError("freeze_contract.reviewer.model must be a non-empty string", path) |
| 437 | if provider not in REVIEWER_PROVIDERS: |
| 438 | raise ConfigError("freeze_contract.reviewer.provider must be local|api", path) |
| 439 | if fallback not in REVIEWER_FALLBACK: |
| 440 | raise ConfigError("freeze_contract.reviewer.fallback must be human", path) |
| 441 | else: |
| 442 | # Human mode: model/provider/fallback optional; unused at runtime (§K5.2 step 6). |
| 443 | model = reviewer_raw.get("model", DEFAULT_REVIEWER_MODEL) |
| 444 | provider = reviewer_raw.get("provider", DEFAULT_REVIEWER_PROVIDER) |
| 445 | fallback = reviewer_raw.get("fallback", DEFAULT_REVIEWER_FALLBACK) |
| 446 | if not isinstance(model, str) or not model.strip(): |
| 447 | model = DEFAULT_REVIEWER_MODEL |
| 448 | if provider not in REVIEWER_PROVIDERS: |
| 449 | provider = DEFAULT_REVIEWER_PROVIDER |
| 450 | if fallback not in REVIEWER_FALLBACK: |
| 451 | fallback = DEFAULT_REVIEWER_FALLBACK |
| 452 | |
| 453 | return ReviewerConfig( |
| 454 | mode=mode, |
| 455 | model=model, |
| 456 | provider=provider, |
| 457 | fallback=fallback, |
| 458 | ) |
| 459 | |
| 460 | |
| 461 | def _parse_lanes(raw_lanes: Any, path: str) -> dict[str, LaneDocsConfig] | None: |
| 462 | """Parse optional ``docs.lanes`` mapping (§K8).""" |
| 463 | if raw_lanes is None: |
| 464 | return None |
| 465 | if not isinstance(raw_lanes, dict): |
| 466 | raise ConfigError("docs.lanes must be a mapping", path) |
| 467 | lanes: dict[str, LaneDocsConfig] = {} |
| 468 | for lane_name, lane_raw in raw_lanes.items(): |
| 469 | if not isinstance(lane_name, str) or not lane_name.strip(): |
| 470 | raise ConfigError("docs.lanes keys must be non-empty strings", path) |
| 471 | lane_map = _require_mapping(lane_raw, f"docs.lanes.{lane_name}", path) |
| 472 | lanes[lane_name.strip()] = LaneDocsConfig( |
| 473 | handover=_require_str(lane_map, "handover", path), |
| 474 | roadmap=_require_str(lane_map, "roadmap", path), |
| 475 | handover_title=_optional_str(lane_map, "handover_title") or "Overseer Handover", |
| 476 | roadmap_title=_optional_str(lane_map, "roadmap_title") or "Roadmap", |
| 477 | ) |
| 478 | return lanes |
| 479 | |
| 480 | |
| 481 | def _validate_lanes_config( |
| 482 | *, |
| 483 | lanes: dict[str, LaneDocsConfig] | None, |
| 484 | default_lane: str | None, |
| 485 | handover: str, |
| 486 | roadmap: str, |
| 487 | handover_title: str, |
| 488 | roadmap_title: str, |
| 489 | path: str, |
| 490 | ) -> None: |
| 491 | """Fail closed on inconsistent multi-lane docs config (§K8).""" |
| 492 | if lanes is None: |
| 493 | if default_lane is not None: |
| 494 | raise ConfigError("docs.default_lane requires docs.lanes", path) |
| 495 | return |
| 496 | if not lanes: |
| 497 | raise ConfigError("docs.lanes must not be empty when present", path) |
| 498 | if not default_lane or not default_lane.strip(): |
| 499 | raise ConfigError("docs.default_lane is required when docs.lanes is set", path) |
| 500 | default_lane = default_lane.strip() |
| 501 | if default_lane not in lanes: |
| 502 | raise ConfigError( |
| 503 | f"docs.default_lane {default_lane!r} is not a key in docs.lanes", |
| 504 | path, |
| 505 | ) |
| 506 | default = lanes[default_lane] |
| 507 | if handover != default.handover or roadmap != default.roadmap: |
| 508 | raise ConfigError( |
| 509 | "docs.handover and docs.roadmap must match docs.lanes[default_lane]", |
| 510 | path, |
| 511 | ) |
| 512 | if handover_title != default.handover_title or roadmap_title != default.roadmap_title: |
| 513 | raise ConfigError( |
| 514 | "docs.handover_title and docs.roadmap_title must match docs.lanes[default_lane]", |
| 515 | path, |
| 516 | ) |
| 517 | |
| 518 | |
| 519 | def resolve_lane_docs(config: OverseerConfig, lane: str | None) -> LaneDocsConfig: |
| 520 | """Return the handover/roadmap pair for ``lane`` or the default lane (§K8).""" |
| 521 | docs = config.docs |
| 522 | if docs.lanes is None: |
| 523 | if lane is not None: |
| 524 | raise ConfigError("docs.lanes is not configured; --lane is not supported") |
| 525 | return LaneDocsConfig( |
| 526 | handover=docs.handover, |
| 527 | roadmap=docs.roadmap, |
| 528 | handover_title=docs.handover_title, |
| 529 | roadmap_title=docs.roadmap_title, |
| 530 | ) |
| 531 | name = (lane or docs.default_lane or "").strip() |
| 532 | if name not in docs.lanes: |
| 533 | known = ", ".join(sorted(docs.lanes)) |
| 534 | raise ConfigError(f"unknown lane {name!r} (configured: {known})") |
| 535 | return docs.lanes[name] |
| 536 | |
| 537 | |
| 538 | def list_configured_lanes(config: OverseerConfig) -> tuple[str, ...]: |
| 539 | """Return sorted lane names; single implicit lane when ``docs.lanes`` is absent.""" |
| 540 | if config.docs.lanes is None: |
| 541 | return ("default",) |
| 542 | return tuple(sorted(config.docs.lanes)) |
| 543 | |
| 544 | |
| 545 | def _validate_regime_fields( |
| 546 | regime: str, |
| 547 | canonical: str, |
| 548 | git: GitConfig, |
| 549 | muse: MuseConfig, |
| 550 | path: str, |
| 551 | ) -> None: |
| 552 | if regime == "git-only": |
| 553 | if canonical != "git": |
| 554 | raise ConfigError("git-only regime requires vcs.canonical: git", path) |
| 555 | if muse.staging_remote is not None or muse.main_branch is not None: |
| 556 | raise ConfigError("git-only regime requires muse fields to be null", path) |
| 557 | return |
| 558 | |
| 559 | if regime == "muse-only": |
| 560 | if canonical != "muse": |
| 561 | raise ConfigError("muse-only regime requires vcs.canonical: muse", path) |
| 562 | if not muse.main_branch: |
| 563 | raise ConfigError("muse-only regime requires vcs.muse.main_branch", path) |
| 564 | return |
| 565 | |
| 566 | if regime == "muse+git-mirror": |
| 567 | if canonical != "muse": |
| 568 | raise ConfigError("muse+git-mirror regime requires vcs.canonical: muse", path) |
| 569 | if not muse.main_branch: |
| 570 | raise ConfigError("muse+git-mirror regime requires vcs.muse.main_branch", path) |
| 571 | if not muse.staging_remote: |
| 572 | raise ConfigError("muse+git-mirror regime requires vcs.muse.staging_remote", path) |
| 573 | if not git.mirror_branch: |
| 574 | raise ConfigError("muse+git-mirror regime requires vcs.git.mirror_branch", path) |
| 575 | |
| 576 | |
| 577 | def _validate_repo_relative_path(value: str, field: str, path: str) -> None: |
| 578 | """Reject absolute paths and ``..`` segments in config path fields.""" |
| 579 | text = value.strip() |
| 580 | if not text: |
| 581 | raise ConfigError(f"{field} must be a non-empty string", path) |
| 582 | candidate = Path(text) |
| 583 | if candidate.is_absolute(): |
| 584 | raise ConfigError(f"{field} must be repo-relative", path) |
| 585 | if ".." in candidate.parts: |
| 586 | raise ConfigError(f"{field} must not contain '..' path segments", path) |
| 587 | |
| 588 | |
| 589 | def _parse_k9_modules( |
| 590 | raw: dict[str, Any], |
| 591 | path: str, |
| 592 | ) -> tuple[CheckpointsConfig, HonestyConfig, ModulesConfig | None, tuple[ExtensionEntry, ...], tuple[str, ...]]: |
| 593 | """Parse optional K9 checkpoints/honesty/modules/extensions (§K9.2).""" |
| 594 | checkpoints = _parse_checkpoints(raw.get("checkpoints"), path) |
| 595 | honesty = _parse_honesty(raw.get("honesty"), path) |
| 596 | modules = _parse_modules(raw.get("modules"), path) |
| 597 | extensions, extension_warnings = _parse_extensions(raw.get("extensions"), path) |
| 598 | |
| 599 | if modules is not None: |
| 600 | if modules.governance_enabled is False: |
| 601 | raise ConfigError("modules.governance.enabled cannot be false", path) |
| 602 | if modules.checkpoints_enabled is not None and modules.checkpoints_enabled != checkpoints.enabled: |
| 603 | raise ConfigError( |
| 604 | "modules.checkpoints.enabled must equal checkpoints.enabled", |
| 605 | path, |
| 606 | ) |
| 607 | if modules.honesty_enabled is not None and modules.honesty_enabled != honesty.enabled: |
| 608 | raise ConfigError( |
| 609 | "modules.honesty.enabled must equal honesty.enabled", |
| 610 | path, |
| 611 | ) |
| 612 | |
| 613 | return checkpoints, honesty, modules, extensions, extension_warnings |
| 614 | |
| 615 | |
| 616 | def _parse_checkpoints(raw_checkpoints: Any, path: str) -> CheckpointsConfig: |
| 617 | if raw_checkpoints is None: |
| 618 | return CheckpointsConfig() |
| 619 | cp_raw = _require_mapping(raw_checkpoints, "checkpoints", path) |
| 620 | extra = set(cp_raw) - CHECKPOINTS_KEYS |
| 621 | if extra: |
| 622 | raise ConfigError(f"unknown checkpoints keys: {sorted(extra)}", path) |
| 623 | |
| 624 | enabled = cp_raw.get("enabled", False) |
| 625 | if not isinstance(enabled, bool): |
| 626 | raise ConfigError("checkpoints.enabled must be a boolean", path) |
| 627 | |
| 628 | policy = _optional_str(cp_raw, "policy") |
| 629 | if policy is not None: |
| 630 | _validate_repo_relative_path(policy, "checkpoints.policy", path) |
| 631 | active_manifest = _optional_str(cp_raw, "active_manifest") |
| 632 | if active_manifest is not None: |
| 633 | _validate_repo_relative_path(active_manifest, "checkpoints.active_manifest", path) |
| 634 | progress = _optional_str(cp_raw, "progress") |
| 635 | if progress is not None: |
| 636 | _validate_repo_relative_path(progress, "checkpoints.progress", path) |
| 637 | orchestrator = _optional_str(cp_raw, "orchestrator") |
| 638 | if orchestrator is not None: |
| 639 | _validate_repo_relative_path(orchestrator, "checkpoints.orchestrator", path) |
| 640 | |
| 641 | allow_hand = cp_raw.get("allow_hand_verified", False) |
| 642 | if not isinstance(allow_hand, bool): |
| 643 | raise ConfigError("checkpoints.allow_hand_verified must be a boolean", path) |
| 644 | if allow_hand: |
| 645 | raise ConfigError("checkpoints.allow_hand_verified is forbidden", path) |
| 646 | |
| 647 | if enabled and (policy is None or not policy.strip()): |
| 648 | raise ConfigError("checkpoints.enabled requires non-empty checkpoints.policy", path) |
| 649 | |
| 650 | return CheckpointsConfig( |
| 651 | enabled=enabled, |
| 652 | policy=policy, |
| 653 | active_manifest=active_manifest, |
| 654 | progress=progress, |
| 655 | orchestrator=orchestrator, |
| 656 | allow_hand_verified=allow_hand, |
| 657 | ) |
| 658 | |
| 659 | |
| 660 | def _parse_honesty(raw_honesty: Any, path: str) -> HonestyConfig: |
| 661 | if raw_honesty is None: |
| 662 | return HonestyConfig() |
| 663 | h_raw = _require_mapping(raw_honesty, "honesty", path) |
| 664 | extra = set(h_raw) - HONESTY_KEYS |
| 665 | if extra: |
| 666 | raise ConfigError(f"unknown honesty keys: {sorted(extra)}", path) |
| 667 | |
| 668 | enabled = h_raw.get("enabled", False) |
| 669 | if not isinstance(enabled, bool): |
| 670 | raise ConfigError("honesty.enabled must be a boolean", path) |
| 671 | |
| 672 | ledger = _optional_str(h_raw, "ledger") |
| 673 | if ledger is not None: |
| 674 | _validate_repo_relative_path(ledger, "honesty.ledger", path) |
| 675 | roles_file = _optional_str(h_raw, "roles_file") |
| 676 | if roles_file is not None: |
| 677 | _validate_repo_relative_path(roles_file, "honesty.roles_file", path) |
| 678 | ci_reexecutor = _optional_str(h_raw, "ci_reexecutor") |
| 679 | if ci_reexecutor is not None: |
| 680 | _validate_repo_relative_path(ci_reexecutor, "honesty.ci_reexecutor", path) |
| 681 | |
| 682 | require_l1 = h_raw.get("require_l1_evidence", "warn") |
| 683 | if not isinstance(require_l1, str) or require_l1 not in L1_EVIDENCE_MODES: |
| 684 | raise ConfigError("honesty.require_l1_evidence must be off|warn|require", path) |
| 685 | |
| 686 | allow_signed = h_raw.get("allow_signed_approval", False) |
| 687 | if not isinstance(allow_signed, bool): |
| 688 | raise ConfigError("honesty.allow_signed_approval must be a boolean", path) |
| 689 | |
| 690 | require_agent_signature = h_raw.get("require_agent_signature", False) |
| 691 | if not isinstance(require_agent_signature, bool): |
| 692 | raise ConfigError("honesty.require_agent_signature must be a boolean", path) |
| 693 | |
| 694 | require_verdict_on = _parse_require_verdict_on(h_raw.get("require_verdict_on"), path) |
| 695 | |
| 696 | if enabled and (ledger is None or not ledger.strip()): |
| 697 | raise ConfigError("honesty.enabled requires non-empty honesty.ledger", path) |
| 698 | |
| 699 | return HonestyConfig( |
| 700 | enabled=enabled, |
| 701 | ledger=ledger, |
| 702 | roles_file=roles_file, |
| 703 | require_verdict_on=require_verdict_on, |
| 704 | require_l1_evidence=require_l1, |
| 705 | allow_signed_approval=allow_signed, |
| 706 | ci_reexecutor=ci_reexecutor, |
| 707 | require_agent_signature=require_agent_signature, |
| 708 | ) |
| 709 | |
| 710 | |
| 711 | def _parse_require_verdict_on(raw_value: Any, path: str) -> frozenset[str]: |
| 712 | if raw_value is None: |
| 713 | return frozenset(HOOK_NAMES) |
| 714 | if not isinstance(raw_value, list): |
| 715 | raise ConfigError("honesty.require_verdict_on must be a list or null", path) |
| 716 | if not raw_value: |
| 717 | raise ConfigError("honesty.require_verdict_on must not be empty", path) |
| 718 | hooks: set[str] = set() |
| 719 | for item in raw_value: |
| 720 | if not isinstance(item, str) or item not in HOOK_NAMES: |
| 721 | raise ConfigError( |
| 722 | "honesty.require_verdict_on entries must be board_done|handoff|register", |
| 723 | path, |
| 724 | ) |
| 725 | hooks.add(item) |
| 726 | return frozenset(hooks) |
| 727 | |
| 728 | |
| 729 | def _parse_modules(raw_modules: Any, path: str) -> ModulesConfig | None: |
| 730 | if raw_modules is None: |
| 731 | return None |
| 732 | m_raw = _require_mapping(raw_modules, "modules", path) |
| 733 | allowed = {"governance", "checkpoints", "honesty"} |
| 734 | extra = set(m_raw) - allowed |
| 735 | if extra: |
| 736 | raise ConfigError(f"unknown modules keys: {sorted(extra)}", path) |
| 737 | |
| 738 | governance_enabled = True |
| 739 | if "governance" in m_raw: |
| 740 | gov_raw = _require_mapping(m_raw["governance"], "modules.governance", path) |
| 741 | gov_extra = set(gov_raw) - MODULES_GOVERNANCE_KEYS |
| 742 | if gov_extra: |
| 743 | raise ConfigError(f"unknown modules.governance keys: {sorted(gov_extra)}", path) |
| 744 | gov_enabled = gov_raw.get("enabled", True) |
| 745 | if not isinstance(gov_enabled, bool): |
| 746 | raise ConfigError("modules.governance.enabled must be a boolean", path) |
| 747 | governance_enabled = gov_enabled |
| 748 | |
| 749 | checkpoints_enabled: bool | None = None |
| 750 | if "checkpoints" in m_raw: |
| 751 | cp_raw = _require_mapping(m_raw["checkpoints"], "modules.checkpoints", path) |
| 752 | cp_extra = set(cp_raw) - MODULES_CHECKPOINTS_KEYS |
| 753 | if cp_extra: |
| 754 | raise ConfigError(f"unknown modules.checkpoints keys: {sorted(cp_extra)}", path) |
| 755 | value = cp_raw.get("enabled") |
| 756 | if not isinstance(value, bool): |
| 757 | raise ConfigError("modules.checkpoints.enabled must be a boolean", path) |
| 758 | checkpoints_enabled = value |
| 759 | |
| 760 | honesty_enabled: bool | None = None |
| 761 | if "honesty" in m_raw: |
| 762 | h_raw = _require_mapping(m_raw["honesty"], "modules.honesty", path) |
| 763 | h_extra = set(h_raw) - MODULES_HONESTY_KEYS |
| 764 | if h_extra: |
| 765 | raise ConfigError(f"unknown modules.honesty keys: {sorted(h_extra)}", path) |
| 766 | value = h_raw.get("enabled") |
| 767 | if not isinstance(value, bool): |
| 768 | raise ConfigError("modules.honesty.enabled must be a boolean", path) |
| 769 | honesty_enabled = value |
| 770 | |
| 771 | return ModulesConfig( |
| 772 | governance_enabled=governance_enabled, |
| 773 | checkpoints_enabled=checkpoints_enabled, |
| 774 | honesty_enabled=honesty_enabled, |
| 775 | ) |
| 776 | |
| 777 | |
| 778 | def _parse_governance_gates(raw_gates: Any, path: str) -> GovernanceGatesConfig: |
| 779 | """Parse optional ``governance_gates`` section (§KH1.9).""" |
| 780 | if raw_gates is None: |
| 781 | return GovernanceGatesConfig() |
| 782 | gates_raw = _require_mapping(raw_gates, "governance_gates", path) |
| 783 | extra = set(gates_raw) - GOVERNANCE_GATES_KEYS |
| 784 | if extra: |
| 785 | raise ConfigError(f"unknown governance_gates keys: {sorted(extra)}", path) |
| 786 | |
| 787 | remind = gates_raw.get("remind", True) |
| 788 | if not isinstance(remind, bool): |
| 789 | raise ConfigError("governance_gates.remind must be a boolean", path) |
| 790 | |
| 791 | freeze_required = True |
| 792 | if "freeze_review" in gates_raw: |
| 793 | freeze_raw = _require_mapping(gates_raw["freeze_review"], "governance_gates.freeze_review", path) |
| 794 | freeze_extra = set(freeze_raw) - {"required_before_auto"} |
| 795 | if freeze_extra: |
| 796 | raise ConfigError( |
| 797 | f"unknown governance_gates.freeze_review keys: {sorted(freeze_extra)}", |
| 798 | path, |
| 799 | ) |
| 800 | value = freeze_raw.get("required_before_auto", True) |
| 801 | if not isinstance(value, bool): |
| 802 | raise ConfigError( |
| 803 | "governance_gates.freeze_review.required_before_auto must be a boolean", |
| 804 | path, |
| 805 | ) |
| 806 | freeze_required = value |
| 807 | |
| 808 | build_required = True |
| 809 | if "build_verification" in gates_raw: |
| 810 | build_raw = _require_mapping( |
| 811 | gates_raw["build_verification"], |
| 812 | "governance_gates.build_verification", |
| 813 | path, |
| 814 | ) |
| 815 | build_extra = set(build_raw) - {"required_before_done"} |
| 816 | if build_extra: |
| 817 | raise ConfigError( |
| 818 | f"unknown governance_gates.build_verification keys: {sorted(build_extra)}", |
| 819 | path, |
| 820 | ) |
| 821 | value = build_raw.get("required_before_done", True) |
| 822 | if not isinstance(value, bool): |
| 823 | raise ConfigError( |
| 824 | "governance_gates.build_verification.required_before_done must be a boolean", |
| 825 | path, |
| 826 | ) |
| 827 | build_required = value |
| 828 | |
| 829 | surfaces: frozenset[str] = frozenset(GOVERNANCE_GATES_SURFACES) |
| 830 | if "surfaces" in gates_raw: |
| 831 | raw_surfaces = gates_raw["surfaces"] |
| 832 | if not isinstance(raw_surfaces, list) or not raw_surfaces: |
| 833 | raise ConfigError("governance_gates.surfaces must be a non-empty list", path) |
| 834 | parsed: set[str] = set() |
| 835 | for item in raw_surfaces: |
| 836 | if not isinstance(item, str) or item not in GOVERNANCE_GATES_SURFACES: |
| 837 | raise ConfigError( |
| 838 | "governance_gates.surfaces entries must be status|governance-sync|handover-paste", |
| 839 | path, |
| 840 | ) |
| 841 | parsed.add(item) |
| 842 | surfaces = frozenset(parsed) |
| 843 | |
| 844 | return GovernanceGatesConfig( |
| 845 | remind=remind, |
| 846 | freeze_review_required=freeze_required, |
| 847 | build_verification_required=build_required, |
| 848 | surfaces=surfaces, |
| 849 | ) |
| 850 | |
| 851 | |
| 852 | def _parse_extensions( |
| 853 | raw_extensions: Any, |
| 854 | path: str, |
| 855 | ) -> tuple[tuple[ExtensionEntry, ...], tuple[str, ...]]: |
| 856 | if raw_extensions is None: |
| 857 | return (), () |
| 858 | if not isinstance(raw_extensions, list): |
| 859 | raise ConfigError("extensions must be a list", path) |
| 860 | |
| 861 | entries: list[ExtensionEntry] = [] |
| 862 | warnings: list[str] = [] |
| 863 | for index, item in enumerate(raw_extensions): |
| 864 | prefix = f"extensions[{index}]" |
| 865 | if not isinstance(item, dict): |
| 866 | raise ConfigError(f"{prefix} must be a mapping", path) |
| 867 | extra = set(item) - EXTENSION_KEYS |
| 868 | if extra: |
| 869 | raise ConfigError(f"unknown {prefix} keys: {sorted(extra)}", path) |
| 870 | ext_id = item.get("id") |
| 871 | if not isinstance(ext_id, str) or not ext_id.strip(): |
| 872 | raise ConfigError(f"{prefix}.id must be a non-empty string", path) |
| 873 | schema_version = item.get("schema_version") |
| 874 | if not isinstance(schema_version, int): |
| 875 | raise ConfigError(f"{prefix}.schema_version must be an integer", path) |
| 876 | config_path = item.get("config_path") |
| 877 | if not isinstance(config_path, str) or not config_path.strip(): |
| 878 | raise ConfigError(f"{prefix}.config_path must be a non-empty string", path) |
| 879 | _validate_repo_relative_path(config_path, f"{prefix}.config_path", path) |
| 880 | entries.append( |
| 881 | ExtensionEntry(id=ext_id.strip(), schema_version=schema_version, config_path=config_path) |
| 882 | ) |
| 883 | warnings.append( |
| 884 | f"extensions[{index}] id={ext_id!r} schema_version={schema_version} ignored (v1 registry empty)" |
| 885 | ) |
| 886 | return tuple(entries), tuple(warnings) |
File History
1 commit
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439
feat: K1-P1 complete — agent provenance, build-verification…
Sonnet 4.6
patch
54 days ago