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,351 @@
1
+ """Environment and configured agent diagnostics for the operator CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ from shikiru.agents.contracts import (
13
+ DOCTOR_TIMEOUT_SECONDS,
14
+ CheckResult,
15
+ ModelCatalog,
16
+ assess_model_choice,
17
+ )
18
+ from shikiru.config.loader import load_settings
19
+ from shikiru.domain.versions import Version, parse_version
20
+ from shikiru.errors import ConfigError, ShikiruError
21
+ from shikiru.operator.detectors import not_repository_root
22
+ from shikiru.paths import engine_output_path, project_config_path, state_db_path
23
+ from shikiru.store.database import Database
24
+ from shikiru.tmux.client import Tmux, private_socket_name
25
+ from shikiru.tmux.session import DETACHED_SIZE
26
+ from shikiru.workspace.caches import (
27
+ CACHE_COW_UNSUPPORTED_WARNING,
28
+ CacheCloner,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Sequence
33
+
34
+ from shikiru.agents.contracts import Rung
35
+ from shikiru.clock import Clock
36
+ from shikiru.config.settings import Settings
37
+ from shikiru.operator.plugin_checks import PluginChecks
38
+ from shikiru.operator.probe import AgentProbe
39
+ from shikiru.process import ProcessResult, SubprocessRunner
40
+ from shikiru.workspace.git import Git
41
+
42
+
43
+ SESSION_SUFFIX_LENGTH = 8
44
+ TMUX_VERSION_ARGS = ("tmux", "-V")
45
+ TMUX_MINIMUM_VERSION = (3, 5)
46
+ TMUX_SESSION_PREFIX = "shikiru-doctor-"
47
+ TMUX_SESSION_NAME = "probe"
48
+ _NO_TMUX_CONFIG = Path("/dev/null")
49
+ GIT_COMMAND = "git"
50
+ GIT_VERSION_ARGS = (GIT_COMMAND, "--version")
51
+ GIT_MINIMUM_VERSION = (2, 45)
52
+ GIT_CHECK_NAME = "git"
53
+ TMUX_CHECK_NAME = "tmux"
54
+ REPOSITORY_CHECK_NAME = "repository"
55
+ CONFIG_CHECK_NAME = "config"
56
+ PLUGINS_CHECK_NAME = "plugins"
57
+ PLUGIN_CONFIG_CHECK_NAME = "config.plugins"
58
+ PLUGIN_CHECK_PREFIX = "plugin"
59
+ PLUGIN_CHECK_SEPARATOR = ":"
60
+ MODEL_CHOICES_CHECK_NAME = "model_choices"
61
+ COW_CHECK_NAME = "cow"
62
+ DATABASE_CHECK_NAME = "db"
63
+ ENGINE_OUTPUT_CHECK_NAME = "engine-output"
64
+ PROBE_CHECK_PREFIX = "probe"
65
+
66
+
67
+ class DoctorCheck(BaseModel):
68
+ """One named environment or project diagnostic."""
69
+
70
+ model_config = ConfigDict(extra="forbid", frozen=True)
71
+
72
+ name: str
73
+ ok: bool
74
+ detail: str
75
+
76
+
77
+ class DoctorReport(BaseModel):
78
+ """The complete result of one `doctor` invocation."""
79
+
80
+ model_config = ConfigDict(extra="forbid", frozen=True)
81
+
82
+ ok: bool
83
+ checks: list[DoctorCheck]
84
+
85
+ @classmethod
86
+ def from_checks(cls, checks: Sequence[DoctorCheck]) -> DoctorReport:
87
+ """Build a report whose status reflects every diagnostic."""
88
+ values = list(checks)
89
+ return cls(ok=all(check.ok for check in values), checks=values)
90
+
91
+
92
+ class Doctor:
93
+ """Run diagnostics through the same boundaries used by the application."""
94
+
95
+ def __init__(
96
+ self,
97
+ project_root: Path,
98
+ *,
99
+ cache_cloner: CacheCloner,
100
+ git: Git,
101
+ runner: SubprocessRunner,
102
+ clock: Clock,
103
+ plugin_checks: PluginChecks,
104
+ probe: AgentProbe,
105
+ ) -> None:
106
+ """Store the project and injected git, process, clock, plugin and probe boundaries."""
107
+ self._project_root = project_root.resolve()
108
+ self._cache_cloner = cache_cloner
109
+ self._git = git
110
+ self._runner = runner
111
+ self._clock = clock
112
+ self._plugins = plugin_checks
113
+ self._probe = probe
114
+
115
+ async def run(self, overrides: Sequence[str] = (), *, probe: bool = False) -> DoctorReport:
116
+ """Run core and plugin checks, plus one live turn per distinct ladder rung with `probe`."""
117
+ checks: list[DoctorCheck] = []
118
+ checks.append(await self._tmux_check())
119
+ checks.append(await self._git_check())
120
+ checks.append(await self._repository_check())
121
+ settings, config_check = self._settings_check(overrides)
122
+ checks.append(config_check)
123
+ checks.append(await self._database_check())
124
+ checks.extend(self._engine_output_check())
125
+ checks.append(await self._cow_check())
126
+ if settings is None:
127
+ checks.append(
128
+ DoctorCheck(name=PLUGIN_CONFIG_CHECK_NAME, ok=False, detail="config unavailable")
129
+ )
130
+ return DoctorReport.from_checks(checks)
131
+ plugin_checks = await self._plugin_checks(settings)
132
+ checks.extend(plugin_checks)
133
+ plugin_ok = bool(settings.agents) and all(check.ok for check in plugin_checks)
134
+ if plugin_ok:
135
+ plugins_detail = "all configured plugins passed"
136
+ elif not settings.agents:
137
+ plugins_detail = "no configured plugins"
138
+ else:
139
+ plugins_detail = "one or more plugins failed"
140
+ checks.append(DoctorCheck(name=PLUGINS_CHECK_NAME, ok=plugin_ok, detail=plugins_detail))
141
+ if probe:
142
+ checks.extend(await self._probe_checks(settings))
143
+ return DoctorReport.from_checks(checks)
144
+
145
+ async def _probe_checks(self, settings: Settings) -> list[DoctorCheck]:
146
+ checks: list[DoctorCheck] = []
147
+ probed_agents: set[str] = set()
148
+ for rung in dict.fromkeys(settings.ladder_rungs()):
149
+ probe = self._probe.smoke if rung.agent in probed_agents else self._probe.run
150
+ probed_agents.add(rung.agent)
151
+ outcome = await probe(
152
+ self._project_root, rung, settings.agents[rung.agent].options, settings.doctor
153
+ )
154
+ checks.append(
155
+ DoctorCheck(name=_probe_check_name(rung), ok=outcome.ok, detail=outcome.detail)
156
+ )
157
+ return checks
158
+
159
+ async def _tmux_check(self) -> DoctorCheck:
160
+ socket_name = private_socket_name(TMUX_SESSION_PREFIX)
161
+ tmux = Tmux(self._runner, socket=socket_name, config_file=_NO_TMUX_CONFIG)
162
+ try:
163
+ version = await self._runner.run(
164
+ TMUX_VERSION_ARGS,
165
+ timeout=DOCTOR_TIMEOUT_SECONDS,
166
+ check=False,
167
+ )
168
+ problem = _version_problem(version, TMUX_MINIMUM_VERSION)
169
+ if problem is None:
170
+ await tmux.new_session(
171
+ f"{TMUX_SESSION_NAME}-{uuid.uuid4().hex[:SESSION_SUFFIX_LENGTH]}",
172
+ cwd=self._project_root,
173
+ env={},
174
+ window_name=TMUX_SESSION_NAME,
175
+ size=DETACHED_SIZE,
176
+ )
177
+ check = DoctorCheck(
178
+ name=TMUX_CHECK_NAME, ok=problem is None, detail=problem or _process_detail(version)
179
+ )
180
+ except (OSError, ShikiruError) as error:
181
+ check = DoctorCheck(name=TMUX_CHECK_NAME, ok=False, detail=str(error))
182
+ try:
183
+ await tmux.kill_server()
184
+ except (OSError, ShikiruError) as error:
185
+ check = DoctorCheck(
186
+ name=TMUX_CHECK_NAME,
187
+ ok=False,
188
+ detail=f"{check.detail}; cleanup failed: {_error_detail(error)}",
189
+ )
190
+ return check
191
+
192
+ async def _git_check(self) -> DoctorCheck:
193
+ try:
194
+ result = await self._runner.run(
195
+ GIT_VERSION_ARGS,
196
+ timeout=DOCTOR_TIMEOUT_SECONDS,
197
+ check=False,
198
+ )
199
+ except (OSError, ShikiruError) as error:
200
+ return DoctorCheck(name=GIT_CHECK_NAME, ok=False, detail=str(error))
201
+ problem = _version_problem(result, GIT_MINIMUM_VERSION)
202
+ return DoctorCheck(
203
+ name=GIT_CHECK_NAME,
204
+ ok=problem is None,
205
+ detail=problem or _process_detail(result),
206
+ )
207
+
208
+ async def _repository_check(self) -> DoctorCheck:
209
+ try:
210
+ is_root = await self._git.is_repository_root(self._project_root)
211
+ except (OSError, ShikiruError) as error:
212
+ return DoctorCheck(name=REPOSITORY_CHECK_NAME, ok=False, detail=str(error))
213
+ detail = str(self._project_root) if is_root else not_repository_root(self._project_root)
214
+ return DoctorCheck(name=REPOSITORY_CHECK_NAME, ok=is_root, detail=detail)
215
+
216
+ def _settings_check(self, overrides: Sequence[str]) -> tuple[Settings | None, DoctorCheck]:
217
+ config_path = project_config_path(self._project_root)
218
+ if not config_path.is_file():
219
+ return None, DoctorCheck(name=CONFIG_CHECK_NAME, ok=False, detail="config missing")
220
+ try:
221
+ settings = load_settings(self._project_root, overrides)
222
+ except (ConfigError, OSError) as error:
223
+ return None, DoctorCheck(name=CONFIG_CHECK_NAME, ok=False, detail=str(error))
224
+ return settings, DoctorCheck(name=CONFIG_CHECK_NAME, ok=True, detail=str(config_path))
225
+
226
+ async def _database_check(self) -> DoctorCheck:
227
+ database = Database(state_db_path(self._project_root))
228
+ try:
229
+ version = await asyncio.to_thread(database.health)
230
+ except (OSError, ShikiruError) as error:
231
+ return DoctorCheck(name=DATABASE_CHECK_NAME, ok=False, detail=str(error))
232
+ return DoctorCheck(name=DATABASE_CHECK_NAME, ok=True, detail=f"schema {version}")
233
+
234
+ def _engine_output_check(self) -> list[DoctorCheck]:
235
+ path = engine_output_path(self._project_root)
236
+ if not path.is_file() or path.stat().st_size == 0:
237
+ return []
238
+ return [
239
+ DoctorCheck(
240
+ name=ENGINE_OUTPUT_CHECK_NAME,
241
+ ok=True,
242
+ detail=f"the last engine start printed output: {path}",
243
+ )
244
+ ]
245
+
246
+ async def _cow_check(self) -> DoctorCheck:
247
+ try:
248
+ supported = await self._cache_cloner.check_support(self._project_root)
249
+ except (OSError, ShikiruError) as error:
250
+ return DoctorCheck(name=COW_CHECK_NAME, ok=False, detail=str(error))
251
+ detail = "copy-on-write supported" if supported else CACHE_COW_UNSUPPORTED_WARNING
252
+ return DoctorCheck(name=COW_CHECK_NAME, ok=True, detail=detail)
253
+
254
+ async def _plugin_checks(self, settings: Settings) -> list[DoctorCheck]:
255
+ checks: list[DoctorCheck] = []
256
+ rungs = tuple(dict.fromkeys(settings.ladder_rungs()))
257
+ for name, agent_config in sorted(settings.agents.items()):
258
+ configured_rungs = tuple(rung for rung in rungs if rung.agent == name)
259
+ result = await self._plugins.check(
260
+ name,
261
+ agent_config.options,
262
+ settings.ladder_models(name),
263
+ include_model_catalog=bool(configured_rungs),
264
+ )
265
+ if result.error is not None:
266
+ checks.append(
267
+ DoctorCheck(name=_plugin_check_name(name), ok=False, detail=result.detail)
268
+ )
269
+ continue
270
+ plugin_results = list(result.checks)
271
+ choice_check = None
272
+ if configured_rungs and result.catalog is not None:
273
+ choice_check = _model_choices_check(configured_rungs, result.catalog)
274
+ plugin_results.append(choice_check)
275
+ checks.extend(
276
+ DoctorCheck(
277
+ name=_plugin_result_name(name, plugin_result.name),
278
+ ok=plugin_result.ok,
279
+ detail=plugin_result.detail,
280
+ )
281
+ for plugin_result in plugin_results
282
+ )
283
+ plugin_ok = result.ok and (choice_check is None or choice_check.ok)
284
+ plugin_detail = result.detail
285
+ if choice_check is not None and not choice_check.ok:
286
+ plugin_detail = f"{plugin_detail}; {choice_check.name}: {choice_check.detail}"
287
+ checks.append(
288
+ DoctorCheck(name=_plugin_check_name(name), ok=plugin_ok, detail=plugin_detail)
289
+ )
290
+ return checks
291
+
292
+
293
+ def _process_detail(result: ProcessResult) -> str:
294
+ return result.stdout.strip() or result.stderr.strip() or f"exit {result.returncode}"
295
+
296
+
297
+ def _version_problem(result: ProcessResult, minimum: Version) -> str | None:
298
+ detail = _process_detail(result)
299
+ if result.returncode != 0:
300
+ return detail
301
+ found = parse_version(detail)
302
+ if found is None:
303
+ return f"{detail}: no version found"
304
+ if found < minimum:
305
+ return f"{detail}: {minimum[0]}.{minimum[1]} or newer is required"
306
+ return None
307
+
308
+
309
+ def _error_detail(error: BaseException) -> str:
310
+ return str(error) or type(error).__name__
311
+
312
+
313
+ def _probe_check_name(rung: Rung) -> str:
314
+ efforts = () if rung.effort is None else (rung.effort,)
315
+ return ":".join((PROBE_CHECK_PREFIX, rung.agent, rung.model, *efforts))
316
+
317
+
318
+ def _plugin_check_name(name: str) -> str:
319
+ return PLUGIN_CHECK_SEPARATOR.join((PLUGIN_CHECK_PREFIX, name))
320
+
321
+
322
+ def _plugin_result_name(name: str, result: str) -> str:
323
+ return PLUGIN_CHECK_SEPARATOR.join((PLUGIN_CHECK_PREFIX, name, result))
324
+
325
+
326
+ def _model_choices_check(rungs: Sequence[Rung], catalog: ModelCatalog) -> CheckResult:
327
+ choices = tuple(dict.fromkeys((rung.model, rung.effort) for rung in rungs))
328
+ assessments = tuple(assess_model_choice(model, effort, catalog) for model, effort in choices)
329
+ invalid = tuple(
330
+ assessment.detail for assessment in assessments if assessment.supported is False
331
+ )
332
+ unverified = tuple(
333
+ assessment.detail for assessment in assessments if assessment.supported is None
334
+ )
335
+ if invalid:
336
+ return CheckResult(
337
+ name=MODEL_CHOICES_CHECK_NAME,
338
+ ok=False,
339
+ detail="; ".join((*invalid, *unverified)),
340
+ )
341
+ if unverified:
342
+ return CheckResult(
343
+ name=MODEL_CHOICES_CHECK_NAME,
344
+ ok=True,
345
+ detail=f"model and effort choices could not be fully verified: {'; '.join(unverified)}",
346
+ )
347
+ return CheckResult(
348
+ name=MODEL_CHOICES_CHECK_NAME,
349
+ ok=True,
350
+ detail="configured model and effort choices are listed in the agent catalog",
351
+ )
@@ -0,0 +1,57 @@
1
+ """Event reads and bounded long-polling for operators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Sequence
10
+
11
+ from shikiru.clock import Clock
12
+ from shikiru.domain.events import Event, EventKind
13
+ from shikiru.store.reader import StateReader
14
+
15
+ EVENT_POLL_SECONDS = 0.5
16
+ MAX_WAIT_SECONDS = 300.0
17
+
18
+
19
+ class EventFeed:
20
+ """Read and wait for state events through a read-only `StateReader`."""
21
+
22
+ def __init__(self, reader: StateReader, clock: Clock) -> None:
23
+ """Use `reader` for event rows and `clock` for deterministic long-poll delays."""
24
+ self._reader = reader
25
+ self._clock = clock
26
+
27
+ def read(
28
+ self,
29
+ since: int,
30
+ *,
31
+ kinds: Sequence[EventKind] | None = None,
32
+ task: str | None = None,
33
+ ) -> list[Event]:
34
+ """Return events after `since`, with optional kind and task filters."""
35
+ return self._reader.events_since(since, kinds=kinds, task_id=task)
36
+
37
+ async def wait(
38
+ self,
39
+ since: int,
40
+ *,
41
+ kinds: Sequence[EventKind] | None = None,
42
+ task: str | None = None,
43
+ timeout: float = MAX_WAIT_SECONDS,
44
+ ) -> list[Event]:
45
+ """Wait until matching events arrive or `timeout` seconds elapse."""
46
+ if timeout < 0 or timeout > MAX_WAIT_SECONDS:
47
+ message = f"timeout must be between 0 and {MAX_WAIT_SECONDS:g} seconds"
48
+ raise ValueError(message)
49
+ deadline = self._clock.now() + timedelta(seconds=timeout)
50
+ while True:
51
+ events = self.read(since, kinds=kinds, task=task)
52
+ if events:
53
+ return events
54
+ if self._clock.now() >= deadline:
55
+ return []
56
+ remaining = (deadline - self._clock.now()).total_seconds()
57
+ await self._clock.sleep(min(EVENT_POLL_SECONDS, remaining))