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.
Files changed (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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
@@ -0,0 +1,3 @@
1
+ """The Textual dashboard: `DashboardApp` and the widget plugin contract."""
2
+
3
+ from __future__ import annotations