config.py python
1,206 lines 45.2 KB
Raw
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