shikiru-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
shikiru/config/loader.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Merge packaged defaults, the project file and `--set` overrides into `Settings`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from shikiru.config.settings import (
|
|
13
|
+
OrchestrationConfig,
|
|
14
|
+
SessionSettings,
|
|
15
|
+
Settings,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.errors import ConfigError
|
|
18
|
+
from shikiru.paths import PROJECT_CONFIG_RELATIVE_PATH, project_config_path
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from pydantic_core import ErrorDetails
|
|
25
|
+
|
|
26
|
+
_PACKAGED_DEFAULTS_SOURCE = "defaults.toml"
|
|
27
|
+
_SET_OVERRIDE_SOURCE = "--set"
|
|
28
|
+
_BARE_WORD = re.compile(r"[A-Za-z0-9_-]+\Z")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_settings(project_dir: Path, overrides: Sequence[str] = ()) -> Settings:
|
|
32
|
+
"""Merge packaged defaults, `config.toml` and `--set` overrides into `Settings`."""
|
|
33
|
+
merged, sources = _load_defaults()
|
|
34
|
+
project_file = project_config_path(project_dir)
|
|
35
|
+
if project_file.is_file():
|
|
36
|
+
project_data = _parse_toml(project_file, PROJECT_CONFIG_RELATIVE_PATH)
|
|
37
|
+
merged = _deep_merge(merged, project_data)
|
|
38
|
+
_record_sources(sources, project_data, PROJECT_CONFIG_RELATIVE_PATH)
|
|
39
|
+
for override in overrides:
|
|
40
|
+
key_path, value = _parse_set_override(override)
|
|
41
|
+
fragment = _nest(key_path, value)
|
|
42
|
+
merged = _deep_merge(merged, fragment)
|
|
43
|
+
sources[key_path] = _SET_OVERRIDE_SOURCE
|
|
44
|
+
try:
|
|
45
|
+
return Settings.model_validate(merged)
|
|
46
|
+
except ValidationError as error:
|
|
47
|
+
raise ConfigError(_format_validation_error(error, sources)) from error
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_session_settings(overrides: Sequence[str] = ()) -> SessionSettings:
|
|
51
|
+
"""Load packaged session settings and applicable `--set` overrides outside a project."""
|
|
52
|
+
merged, _sources = _load_defaults()
|
|
53
|
+
try:
|
|
54
|
+
for override in overrides:
|
|
55
|
+
key_path, value = _parse_set_override(override)
|
|
56
|
+
merged = _deep_merge(merged, _nest(key_path, value))
|
|
57
|
+
return SessionSettings.model_validate(merged["sessions"])
|
|
58
|
+
except (KeyError, ValidationError) as error:
|
|
59
|
+
message = f"defaults.toml: invalid sessions settings: {error}"
|
|
60
|
+
raise ConfigError(message) from error
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_default_orchestration() -> OrchestrationConfig:
|
|
64
|
+
"""Return the packaged orchestration values used to seed the init wizard."""
|
|
65
|
+
defaults, _ = _load_defaults()
|
|
66
|
+
return OrchestrationConfig.model_validate(defaults["orchestration"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_project_config_text(text: str) -> Settings:
|
|
70
|
+
"""Validate rendered project config against packaged defaults before writing it."""
|
|
71
|
+
merged, sources = _load_defaults()
|
|
72
|
+
project_data = _parse_toml_text(text, PROJECT_CONFIG_RELATIVE_PATH)
|
|
73
|
+
merged = _deep_merge(merged, project_data)
|
|
74
|
+
_record_sources(sources, project_data, PROJECT_CONFIG_RELATIVE_PATH)
|
|
75
|
+
try:
|
|
76
|
+
return Settings.model_validate(merged)
|
|
77
|
+
except ValidationError as error:
|
|
78
|
+
raise ConfigError(_format_validation_error(error, sources)) from error
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_defaults() -> tuple[dict[str, Any], dict[str, str]]:
|
|
82
|
+
defaults_text = (
|
|
83
|
+
resources.files("shikiru.config").joinpath("defaults.toml").read_text(encoding="utf-8")
|
|
84
|
+
)
|
|
85
|
+
data = tomllib.loads(defaults_text)
|
|
86
|
+
sources: dict[str, str] = {}
|
|
87
|
+
_record_sources(sources, data, _PACKAGED_DEFAULTS_SOURCE)
|
|
88
|
+
return data, sources
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_toml(path: Path, source: str) -> dict[str, Any]:
|
|
92
|
+
return _parse_toml_text(path.read_text(encoding="utf-8"), source)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_toml_text(text: str, source: str) -> dict[str, Any]:
|
|
96
|
+
try:
|
|
97
|
+
return tomllib.loads(text)
|
|
98
|
+
except tomllib.TOMLDecodeError as error:
|
|
99
|
+
message = f"{source}: {error}"
|
|
100
|
+
raise ConfigError(message) from error
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _record_sources(
|
|
104
|
+
sources: dict[str, str], data: dict[str, Any], source: str, prefix: str = ""
|
|
105
|
+
) -> None:
|
|
106
|
+
for key, value in data.items():
|
|
107
|
+
key_path = f"{prefix}.{key}" if prefix else key
|
|
108
|
+
sources[key_path] = source
|
|
109
|
+
if isinstance(value, dict):
|
|
110
|
+
_record_sources(sources, cast("dict[str, Any]", value), source, key_path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
114
|
+
merged = dict(base)
|
|
115
|
+
for key, value in overlay.items():
|
|
116
|
+
existing = merged.get(key)
|
|
117
|
+
if isinstance(existing, dict) and isinstance(value, dict):
|
|
118
|
+
merged[key] = _deep_merge(
|
|
119
|
+
cast("dict[str, Any]", existing), cast("dict[str, Any]", value)
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
merged[key] = value
|
|
123
|
+
return merged
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _nest(key_path: str, value: Any) -> dict[str, Any]:
|
|
127
|
+
parts = key_path.split(".")
|
|
128
|
+
fragment: dict[str, Any] = value
|
|
129
|
+
for part in reversed(parts):
|
|
130
|
+
fragment = {part: fragment}
|
|
131
|
+
return fragment
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_set_override(text: str) -> tuple[str, Any]:
|
|
135
|
+
key_path, separator, raw_value = text.partition("=")
|
|
136
|
+
if not separator or not key_path:
|
|
137
|
+
message = f"{_SET_OVERRIDE_SOURCE}: {text!r} is not key.path=value"
|
|
138
|
+
raise ConfigError(message)
|
|
139
|
+
try:
|
|
140
|
+
value = _parse_set_value(raw_value)
|
|
141
|
+
except ConfigError as error:
|
|
142
|
+
raise _invalid_set_override(key_path, error) from error
|
|
143
|
+
return key_path, value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _invalid_set_override(key_path: str, error: ConfigError) -> ConfigError:
|
|
147
|
+
message = f"{_SET_OVERRIDE_SOURCE}: {key_path}: {error}"
|
|
148
|
+
return ConfigError(message)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_set_value(raw_value: str) -> Any:
|
|
152
|
+
try:
|
|
153
|
+
return tomllib.loads(f"v = {raw_value}")["v"]
|
|
154
|
+
except tomllib.TOMLDecodeError as error:
|
|
155
|
+
if _BARE_WORD.fullmatch(raw_value):
|
|
156
|
+
return raw_value
|
|
157
|
+
message = f"{raw_value!r} is neither a TOML value nor a bare word"
|
|
158
|
+
raise ConfigError(message) from error
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _format_validation_error(error: ValidationError, sources: dict[str, str]) -> str:
|
|
162
|
+
lines = [_format_single_error(err, sources) for err in error.errors()]
|
|
163
|
+
return "\n".join(lines)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _format_single_error(err: ErrorDetails, sources: dict[str, str]) -> str:
|
|
167
|
+
dotted = ".".join(str(part) for part in err["loc"])
|
|
168
|
+
source = _source_for(dotted, sources)
|
|
169
|
+
return f"{source}: {dotted}: {err['msg']}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _source_for(dotted: str, sources: dict[str, str]) -> str:
|
|
173
|
+
parts = dotted.split(".")
|
|
174
|
+
for end in range(len(parts), 0, -1):
|
|
175
|
+
candidate = ".".join(parts[:end])
|
|
176
|
+
if candidate in sources:
|
|
177
|
+
return sources[candidate]
|
|
178
|
+
return PROJECT_CONFIG_RELATIVE_PATH
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Pydantic settings models for every core configuration key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Annotated, Any, Final, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, model_validator
|
|
9
|
+
|
|
10
|
+
from shikiru.agents.contracts import Rung, parse_rung
|
|
11
|
+
from shikiru.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
ComplexityName = Literal["low", "normal", "high"]
|
|
14
|
+
MERGE_INTEGRATION = "merge"
|
|
15
|
+
SQUASH_INTEGRATION = "squash"
|
|
16
|
+
PULL_REQUEST_INTEGRATION = "pr"
|
|
17
|
+
IntegrationStrategy = Literal["merge", "squash", "pr"]
|
|
18
|
+
CHANGES_REQUESTED_FEEDBACK: Final = "changes_requested"
|
|
19
|
+
ANY_COMMENT_FEEDBACK: Final = "any_comment"
|
|
20
|
+
PullRequestFeedbackScope = Literal["changes_requested", "any_comment"]
|
|
21
|
+
LayoutSplitAxis = Literal["h", "v"]
|
|
22
|
+
DOCKER_SCOPE_ALL = "all"
|
|
23
|
+
DockerScope = Literal["project", "all"]
|
|
24
|
+
PaneName = Literal["system", "leader", "implementer", "shell"]
|
|
25
|
+
GIT_DEFAULT_REMOTE = "origin"
|
|
26
|
+
MAX_MOTION_REFRESH_HZ = 4
|
|
27
|
+
MAX_UNAVAILABLE_RETRIES = 20
|
|
28
|
+
MAX_RETRY_DELAY_SECONDS = timedelta.max.days * 86_400 + timedelta.max.seconds
|
|
29
|
+
MAX_TIMELINE_PAGE_SIZE = 49
|
|
30
|
+
MAX_TIMELINE_SIGNAL_SCAN_ROWS = 1024
|
|
31
|
+
MAX_USAGE_PCT = 100
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigModel(BaseModel):
|
|
35
|
+
"""Base for every settings model: unknown keys are rejected, values are immutable."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProjectConfig(ConfigModel):
|
|
41
|
+
"""`[project]`."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
trackers: list[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RepoConfig(ConfigModel):
|
|
48
|
+
"""`[repo]`: the project's single git repository, rooted at the project root."""
|
|
49
|
+
|
|
50
|
+
base: str | None = None
|
|
51
|
+
caches: list[str] = Field(default_factory=list)
|
|
52
|
+
remote: str = GIT_DEFAULT_REMOTE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SessionSettings(ConfigModel):
|
|
56
|
+
"""`[sessions]`: limits for host-to-host session queries."""
|
|
57
|
+
|
|
58
|
+
ssh_timeout_seconds: float = Field(gt=0)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ResourcesConfig(ConfigModel):
|
|
62
|
+
"""`[orchestration.resources]`."""
|
|
63
|
+
|
|
64
|
+
max_cpu_pct: int
|
|
65
|
+
min_free_ram_gb: float
|
|
66
|
+
sample_seconds: float = Field(gt=0)
|
|
67
|
+
cpu_window_seconds: float = Field(gt=0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TimeoutsConfig(ConfigModel):
|
|
71
|
+
"""`[orchestration.timeouts]`, all values in seconds."""
|
|
72
|
+
|
|
73
|
+
stall: int
|
|
74
|
+
tool: int
|
|
75
|
+
max_quota_wait: int
|
|
76
|
+
integration_wait: int
|
|
77
|
+
pr_wait: int
|
|
78
|
+
helper: int
|
|
79
|
+
cache_copy: int = Field(gt=0)
|
|
80
|
+
git_command: int = Field(gt=0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FaultsConfig(ConfigModel):
|
|
84
|
+
"""`[orchestration.faults]`: how the engine retries a failed tick, in seconds and counts."""
|
|
85
|
+
|
|
86
|
+
backoff_initial_seconds: float = Field(gt=0)
|
|
87
|
+
backoff_max_seconds: float = Field(gt=0)
|
|
88
|
+
unexpected_fault_limit: int = Field(gt=0)
|
|
89
|
+
stop_notify_seconds: float = Field(gt=0)
|
|
90
|
+
repeat_notify_seconds: float = Field(gt=0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RetriesConfig(ConfigModel):
|
|
94
|
+
"""`[orchestration.retries]`: bounded retries for unavailable agent providers."""
|
|
95
|
+
|
|
96
|
+
unavailable_initial_seconds: float = Field(
|
|
97
|
+
gt=0, le=MAX_RETRY_DELAY_SECONDS, allow_inf_nan=False
|
|
98
|
+
)
|
|
99
|
+
unavailable_multiplier: float = Field(gt=1, allow_inf_nan=False)
|
|
100
|
+
unavailable_max_seconds: float = Field(gt=0, le=MAX_RETRY_DELAY_SECONDS, allow_inf_nan=False)
|
|
101
|
+
unavailable_max_retries: int = Field(ge=0, le=MAX_UNAVAILABLE_RETRIES)
|
|
102
|
+
|
|
103
|
+
@model_validator(mode="after")
|
|
104
|
+
def cap_is_at_least_initial_delay(self) -> RetriesConfig:
|
|
105
|
+
if self.unavailable_max_seconds < self.unavailable_initial_seconds:
|
|
106
|
+
message = "unavailable_max_seconds must be at least unavailable_initial_seconds"
|
|
107
|
+
raise ValueError(message)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class NetworkConfig(ConfigModel):
|
|
112
|
+
"""`[orchestration.network]`: bounds of the remote calls pull request mode makes."""
|
|
113
|
+
|
|
114
|
+
fetch_timeout_seconds: float = Field(gt=0)
|
|
115
|
+
poll_attempts: int = Field(gt=0)
|
|
116
|
+
pr_poll_seconds: float = Field(gt=0)
|
|
117
|
+
pr_close_attempts: int = Field(gt=0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class QuotaRefreshConfig(ConfigModel):
|
|
121
|
+
"""`[orchestration.quota_refresh]`: how often a persisted quota block is revalidated."""
|
|
122
|
+
|
|
123
|
+
interval_seconds: float = Field(gt=0)
|
|
124
|
+
timeout_seconds: float = Field(gt=0)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class OrchestrationConfig(ConfigModel):
|
|
128
|
+
"""`[orchestration]`."""
|
|
129
|
+
|
|
130
|
+
max_parallel: int
|
|
131
|
+
max_iterations: int
|
|
132
|
+
integration: IntegrationStrategy
|
|
133
|
+
pr_feedback: PullRequestFeedbackScope
|
|
134
|
+
push_interval_seconds: int = Field(ge=0)
|
|
135
|
+
remote_recheck_seconds: int = Field(gt=0)
|
|
136
|
+
poll_seconds: int
|
|
137
|
+
shutdown_seconds: float = Field(gt=0)
|
|
138
|
+
dispatch_budget_seconds: float = Field(gt=0)
|
|
139
|
+
engine_start_seconds: float = Field(gt=0)
|
|
140
|
+
resources: ResourcesConfig
|
|
141
|
+
timeouts: TimeoutsConfig
|
|
142
|
+
faults: FaultsConfig
|
|
143
|
+
retries: RetriesConfig
|
|
144
|
+
quota_refresh: QuotaRefreshConfig
|
|
145
|
+
network: NetworkConfig
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class EscalationConfig(ConfigModel):
|
|
149
|
+
"""`[escalation]`: how much of the work done before an escalation its packet digests."""
|
|
150
|
+
|
|
151
|
+
tried_lines: int = Field(gt=0)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class AgentConfig(ConfigModel):
|
|
155
|
+
"""`[agents.<name>]`. `options` is validated by that plugin's factory, not here."""
|
|
156
|
+
|
|
157
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _rung_from_short_form(value: object) -> object:
|
|
161
|
+
if not isinstance(value, str):
|
|
162
|
+
return value
|
|
163
|
+
try:
|
|
164
|
+
return parse_rung(value)
|
|
165
|
+
except ConfigError as error:
|
|
166
|
+
raise ValueError(str(error)) from error
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
ConfiguredRung = Annotated[Rung, BeforeValidator(_rung_from_short_form)]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ComplexityLadder(ConfigModel):
|
|
173
|
+
"""One `[roles.implementer.complexity.<name>]` table."""
|
|
174
|
+
|
|
175
|
+
ladder: list[ConfiguredRung]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class RoleConfig(ConfigModel):
|
|
179
|
+
"""A role's base ladder: `[roles.leader]` and `[roles.helper]`."""
|
|
180
|
+
|
|
181
|
+
ladder: list[ConfiguredRung]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class ImplementerConfig(RoleConfig):
|
|
185
|
+
"""`[roles.implementer]`: a base ladder plus escalation and per-complexity overrides."""
|
|
186
|
+
|
|
187
|
+
escalate_after: int
|
|
188
|
+
complexity: dict[ComplexityName, ComplexityLadder] = Field(default_factory=dict)
|
|
189
|
+
|
|
190
|
+
def ladder_for(self, complexity: ComplexityName) -> list[Rung]:
|
|
191
|
+
"""Resolve the ladder for `complexity`, falling back to the base ladder."""
|
|
192
|
+
override = self.complexity.get(complexity)
|
|
193
|
+
return override.ladder if override is not None else self.ladder
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class RolesConfig(ConfigModel):
|
|
197
|
+
"""`[roles]`."""
|
|
198
|
+
|
|
199
|
+
leader: RoleConfig
|
|
200
|
+
implementer: ImplementerConfig
|
|
201
|
+
helper: RoleConfig
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class PaneLeaf(ConfigModel):
|
|
205
|
+
"""A dashboard layout leaf naming the pane it holds."""
|
|
206
|
+
|
|
207
|
+
pane: PaneName
|
|
208
|
+
size: int | None = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class LayoutSplit(ConfigModel):
|
|
212
|
+
"""A dashboard layout split holding nested panes or further splits."""
|
|
213
|
+
|
|
214
|
+
split: LayoutSplitAxis
|
|
215
|
+
panes: list[LayoutNode]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
LayoutNode = LayoutSplit | PaneLeaf
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TitlesConfig(ConfigModel):
|
|
222
|
+
"""`[dashboard.titles]` (field templates and context thresholds)."""
|
|
223
|
+
|
|
224
|
+
window: str
|
|
225
|
+
leader: str
|
|
226
|
+
implementer: str
|
|
227
|
+
system: str
|
|
228
|
+
shell: str
|
|
229
|
+
context_warn_pct: int = Field(gt=0, le=MAX_USAGE_PCT)
|
|
230
|
+
context_alert_pct: int = Field(gt=0, le=MAX_USAGE_PCT)
|
|
231
|
+
|
|
232
|
+
@model_validator(mode="after")
|
|
233
|
+
def _context_warn_below_alert(self) -> TitlesConfig:
|
|
234
|
+
if self.context_warn_pct >= self.context_alert_pct:
|
|
235
|
+
message = (
|
|
236
|
+
"dashboard.titles.context_warn_pct must be below dashboard.titles.context_alert_pct"
|
|
237
|
+
)
|
|
238
|
+
raise ValueError(message)
|
|
239
|
+
return self
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class GaugesConfig(ConfigModel):
|
|
243
|
+
"""`[dashboard.gauges]`: subscription-limit gauge thresholds."""
|
|
244
|
+
|
|
245
|
+
quota_warn_pct: int = Field(gt=0, le=MAX_USAGE_PCT)
|
|
246
|
+
quota_alert_pct: int = Field(gt=0, le=MAX_USAGE_PCT)
|
|
247
|
+
|
|
248
|
+
@model_validator(mode="after")
|
|
249
|
+
def _quota_warn_below_alert(self) -> GaugesConfig:
|
|
250
|
+
if self.quota_warn_pct >= self.quota_alert_pct:
|
|
251
|
+
message = (
|
|
252
|
+
"dashboard.gauges.quota_warn_pct must be below dashboard.gauges.quota_alert_pct"
|
|
253
|
+
)
|
|
254
|
+
raise ValueError(message)
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class DashboardMotionConfig(ConfigModel):
|
|
259
|
+
"""Motion cadence and presentation timings for the dashboard."""
|
|
260
|
+
|
|
261
|
+
refresh_hz: int = Field(gt=0, le=MAX_MOTION_REFRESH_HZ)
|
|
262
|
+
stream_active_seconds: float | None = Field(
|
|
263
|
+
default=None,
|
|
264
|
+
gt=0,
|
|
265
|
+
description=(
|
|
266
|
+
"Deprecated compatibility setting; signal freshness no longer controls live motion."
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
stamp_flash_milliseconds: int = Field(gt=0)
|
|
270
|
+
escalation_pulse_hz: int = Field(gt=0)
|
|
271
|
+
task_event_limit: int = Field(gt=0)
|
|
272
|
+
merge_event_limit: int = Field(gt=0)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class DashboardConfig(ConfigModel):
|
|
276
|
+
"""`[dashboard]`."""
|
|
277
|
+
|
|
278
|
+
widgets: list[str]
|
|
279
|
+
layout: LayoutSplit
|
|
280
|
+
titles: TitlesConfig
|
|
281
|
+
gauges: GaugesConfig
|
|
282
|
+
animations: bool
|
|
283
|
+
motion: DashboardMotionConfig
|
|
284
|
+
docker_refresh_seconds: float = Field(gt=0)
|
|
285
|
+
docker_timeout_seconds: float = Field(gt=0)
|
|
286
|
+
docker_scope: DockerScope
|
|
287
|
+
disk_refresh_seconds: float = Field(gt=0)
|
|
288
|
+
disk_size_refresh_seconds: float = Field(gt=0)
|
|
289
|
+
disk_warn_free_pct: int = Field(gt=0, le=MAX_USAGE_PCT)
|
|
290
|
+
disk_alert_free_pct: int = Field(gt=0, le=MAX_USAGE_PCT)
|
|
291
|
+
repo_status_cache_seconds: float = Field(gt=0)
|
|
292
|
+
task_work_cache_seconds: float = Field(gt=0)
|
|
293
|
+
timeline_page_size: int = Field(gt=0, le=MAX_TIMELINE_PAGE_SIZE)
|
|
294
|
+
timeline_scan_rows: int = Field(gt=0, le=MAX_TIMELINE_SIGNAL_SCAN_ROWS)
|
|
295
|
+
timeline_refresh_seconds: float = Field(gt=0)
|
|
296
|
+
recent_task_limit: int = Field(gt=0)
|
|
297
|
+
recent_task_window: int = Field(gt=0)
|
|
298
|
+
last_words_chars: int = Field(gt=0)
|
|
299
|
+
now_max_lines: int = Field(gt=0)
|
|
300
|
+
now_compact_lines: int = Field(gt=0)
|
|
301
|
+
results_commits: int = Field(gt=0)
|
|
302
|
+
action_confirmation_seconds: float = Field(gt=0)
|
|
303
|
+
|
|
304
|
+
@model_validator(mode="after")
|
|
305
|
+
def _recent_limit_covers_window(self) -> DashboardConfig:
|
|
306
|
+
if self.recent_task_limit < self.recent_task_window:
|
|
307
|
+
message = "dashboard.recent_task_limit must be at least dashboard.recent_task_window"
|
|
308
|
+
raise ValueError(message)
|
|
309
|
+
return self
|
|
310
|
+
|
|
311
|
+
@model_validator(mode="after")
|
|
312
|
+
def _disk_alert_below_warn(self) -> DashboardConfig:
|
|
313
|
+
if self.disk_alert_free_pct >= self.disk_warn_free_pct:
|
|
314
|
+
message = "dashboard.disk_alert_free_pct must be below dashboard.disk_warn_free_pct"
|
|
315
|
+
raise ValueError(message)
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class NotifyConfig(ConfigModel):
|
|
320
|
+
"""`[notify]`: the hook and the seconds it may run before it is killed."""
|
|
321
|
+
|
|
322
|
+
command: str
|
|
323
|
+
timeout_seconds: float = Field(gt=0)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class DoctorConfig(ConfigModel):
|
|
327
|
+
"""`[doctor]`: `doctor --probe` limits."""
|
|
328
|
+
|
|
329
|
+
probe_timeout: float
|
|
330
|
+
probe_stream_tail_lines: int
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class LoggingConfig(ConfigModel):
|
|
334
|
+
"""`[logging]`: engine log rotation."""
|
|
335
|
+
|
|
336
|
+
engine_log_max_bytes: int = Field(gt=0)
|
|
337
|
+
engine_log_backups: int = Field(ge=0)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class RetentionConfig(ConfigModel):
|
|
341
|
+
"""`[retention]`: how long finished history is kept."""
|
|
342
|
+
|
|
343
|
+
interval_seconds: float = Field(gt=0)
|
|
344
|
+
finished_task_days: float = Field(gt=0)
|
|
345
|
+
event_days: float = Field(gt=0)
|
|
346
|
+
command_days: float = Field(gt=0)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class Settings(ConfigModel):
|
|
350
|
+
"""The full merged and validated Shikiru configuration."""
|
|
351
|
+
|
|
352
|
+
project: ProjectConfig
|
|
353
|
+
repo: RepoConfig = Field(default_factory=RepoConfig)
|
|
354
|
+
sessions: SessionSettings
|
|
355
|
+
orchestration: OrchestrationConfig
|
|
356
|
+
escalation: EscalationConfig
|
|
357
|
+
agents: dict[str, AgentConfig] = Field(default_factory=dict)
|
|
358
|
+
roles: RolesConfig
|
|
359
|
+
dashboard: DashboardConfig
|
|
360
|
+
notify: NotifyConfig
|
|
361
|
+
doctor: DoctorConfig
|
|
362
|
+
logging: LoggingConfig
|
|
363
|
+
retention: RetentionConfig
|
|
364
|
+
|
|
365
|
+
def ladder_rungs(self) -> list[Rung]:
|
|
366
|
+
"""Return every configured rung: leader, implementer, complexity overrides, helper."""
|
|
367
|
+
rungs = [*self.roles.leader.ladder, *self.roles.implementer.ladder]
|
|
368
|
+
for override in self.roles.implementer.complexity.values():
|
|
369
|
+
rungs.extend(override.ladder)
|
|
370
|
+
rungs.extend(self.roles.helper.ladder)
|
|
371
|
+
return rungs
|
|
372
|
+
|
|
373
|
+
def ladder_models(self, agent: str) -> tuple[str, ...]:
|
|
374
|
+
"""Return the distinct models the configured ladders select for `agent`."""
|
|
375
|
+
return tuple(sorted({rung.model for rung in self.ladder_rungs() if rung.agent == agent}))
|
|
376
|
+
|
|
377
|
+
@model_validator(mode="after")
|
|
378
|
+
def _every_ladder_agent_has_a_table(self) -> Settings:
|
|
379
|
+
wanted = {rung.agent for rung in self.ladder_rungs()}
|
|
380
|
+
missing = sorted(wanted - self.agents.keys())
|
|
381
|
+
if missing:
|
|
382
|
+
names = ", ".join(missing)
|
|
383
|
+
message = f"no [agents.<name>] table for: {names}"
|
|
384
|
+
raise ValueError(message)
|
|
385
|
+
return self
|
|
386
|
+
|
|
387
|
+
@model_validator(mode="after")
|
|
388
|
+
def _helper_ladder_is_nonempty(self) -> Settings:
|
|
389
|
+
if not self.roles.helper.ladder:
|
|
390
|
+
message = "roles.helper.ladder must contain at least one rung"
|
|
391
|
+
raise ValueError(message)
|
|
392
|
+
return self
|