config.py python
886 lines 32.3 KB
Raw
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