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,182 @@
1
+ """Calls configured Helper headless agents in order and validates every answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
11
+
12
+ from shikiru.agents.contracts import FailureKind, Rung
13
+ from shikiru.domain.fencing import fence
14
+ from shikiru.domain.reports import ReportKind, model_for, schema_for
15
+ from shikiru.errors import (
16
+ CommandFailed,
17
+ CommandTimeoutError,
18
+ ConfigError,
19
+ HeadlessOutputError,
20
+ )
21
+ from shikiru.helper.models import Unrecoverable
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping, Sequence
25
+ from datetime import timedelta
26
+ from pathlib import Path
27
+
28
+ from shikiru.agents.contracts import Agent, HeadlessAgent
29
+ from shikiru.clock import Clock
30
+ from shikiru.domain.reports import Report
31
+
32
+ _REBUILD_PROMPT = (
33
+ "The agent's turn ended without a valid report. Reconstruct the report for this {kind} "
34
+ "turn from its last message and the facts below. If you cannot determine the report with "
35
+ 'confidence, respond instead with \'{{"unrecoverable": "<reason>"}}.\n\n'
36
+ "## Last message\n\n{last_message}\n\n## Facts\n\n{facts}\n"
37
+ )
38
+ _CLASSIFY_PROMPT = (
39
+ "Classify this unrecognised agent failure into QUOTA, UNAVAILABLE, AUTH or OTHER. "
40
+ "Give confidence from 0 to 1 and a reset time only when known. If the text is ambiguous, "
41
+ "return UNKNOWN with low confidence.\n\n## Failure\n\n{failure}\n"
42
+ )
43
+ _EXPECTED_CALL_ERRORS = (
44
+ CommandFailed,
45
+ CommandTimeoutError,
46
+ HeadlessOutputError,
47
+ OSError,
48
+ TimeoutError,
49
+ )
50
+ _LOGGER = logging.getLogger(__name__)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class HelperEntry:
55
+ """One configured Helper rung and the agent selected for it."""
56
+
57
+ rung: Rung
58
+ agent: Agent
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class _HeadlessEntry:
63
+ rung: Rung
64
+ headless: HeadlessAgent
65
+
66
+
67
+ class FailureClassification(BaseModel):
68
+ """The Helper's structured classification of an unrecognised agent failure."""
69
+
70
+ model_config = ConfigDict(extra="forbid", frozen=True)
71
+
72
+ kind: FailureKind
73
+ confidence: float = Field(ge=0, le=1)
74
+ resets_at: datetime | None = None
75
+
76
+
77
+ class Helper:
78
+ """Call configured headless helpers in order and validate their reports and classifications."""
79
+
80
+ def __init__(self, entries: Sequence[HelperEntry], clock: Clock) -> None:
81
+ if not entries:
82
+ message = "roles.helper.ladder must contain at least one rung"
83
+ raise ConfigError(message)
84
+ self._entries = tuple(self._headless_entry(entry) for entry in entries)
85
+ self._clock = clock
86
+
87
+ async def rebuild_report(
88
+ self,
89
+ kind: ReportKind,
90
+ last_message: str,
91
+ facts: str,
92
+ *,
93
+ cwd: Path,
94
+ timeout: timedelta,
95
+ ) -> Report | Unrecoverable:
96
+ """Reconstruct a missing report from the turn's final message and facts."""
97
+ schema = {"oneOf": [schema_for(kind), Unrecoverable.model_json_schema()]}
98
+ prompt = _REBUILD_PROMPT.format(
99
+ kind=kind.value,
100
+ last_message=fence(last_message, "text"),
101
+ facts=fence(facts, "text"),
102
+ )
103
+ return await self._ask(prompt, schema, model_for(kind), cwd, timeout)
104
+
105
+ async def classify_failure(
106
+ self,
107
+ failure: str,
108
+ *,
109
+ cwd: Path,
110
+ timeout: timedelta,
111
+ ) -> FailureClassification | Unrecoverable:
112
+ """Classify only the plugin's unrecognised failure text."""
113
+ schema = {
114
+ "oneOf": [
115
+ FailureClassification.model_json_schema(),
116
+ Unrecoverable.model_json_schema(),
117
+ ]
118
+ }
119
+ prompt = _CLASSIFY_PROMPT.format(failure=fence(failure, "text"))
120
+ return await self._ask(prompt, schema, FailureClassification, cwd, timeout)
121
+
122
+ def _headless_entry(self, entry: HelperEntry) -> _HeadlessEntry:
123
+ headless = entry.agent.headless()
124
+ if headless is None:
125
+ message = f"agent {entry.rung.agent!r} has no headless support required by roles.helper"
126
+ raise ConfigError(message)
127
+ return _HeadlessEntry(entry.rung, headless)
128
+
129
+ async def _ask[ResultT: BaseModel](
130
+ self,
131
+ prompt: str,
132
+ schema: Mapping[str, object],
133
+ model: type[ResultT],
134
+ cwd: Path,
135
+ timeout: timedelta,
136
+ ) -> ResultT | Unrecoverable:
137
+ started_at = self._clock.now()
138
+ failures: list[str] = []
139
+ for entry in self._entries:
140
+ label = f"{entry.rung.agent}:{entry.rung.model}/{entry.rung.effort}"
141
+ remaining = timeout - (self._clock.now() - started_at)
142
+ if remaining.total_seconds() <= 0:
143
+ failures.append(f"{label}: Helper timeout exhausted before call")
144
+ break
145
+ try:
146
+ raw = await entry.headless.ask(
147
+ prompt,
148
+ schema,
149
+ entry.rung.model,
150
+ entry.rung.effort,
151
+ cwd,
152
+ remaining,
153
+ )
154
+ except _EXPECTED_CALL_ERRORS as error:
155
+ failure = f"{label}: {type(error).__name__}: {error}"
156
+ failures.append(failure)
157
+ _LOGGER.warning("Helper rung failed: %s", failure, exc_info=True)
158
+ continue
159
+ result = self._validate(raw, model)
160
+ if not isinstance(result, Unrecoverable):
161
+ return result
162
+ failure = f"{label}: {result.unrecoverable}"
163
+ failures.append(failure)
164
+ _LOGGER.warning("Helper rung returned unusable output: %s", failure)
165
+ return Unrecoverable(unrecoverable=f"Helper ladder exhausted: {'; '.join(failures)}")
166
+
167
+ @staticmethod
168
+ def _validate[ResultT: BaseModel](
169
+ raw: Mapping[str, object], model: type[ResultT]
170
+ ) -> ResultT | Unrecoverable:
171
+ try:
172
+ return model.model_validate(raw)
173
+ except ValidationError as model_error:
174
+ try:
175
+ return Unrecoverable.model_validate(raw)
176
+ except ValidationError as unrecoverable_error:
177
+ reason = (
178
+ f"invalid helper output for {model.__name__}: {raw!r}; "
179
+ f"{model.__name__} errors: {model_error}; "
180
+ f"unrecoverable errors: {unrecoverable_error}"
181
+ )
182
+ return Unrecoverable(unrecoverable=reason)
@@ -0,0 +1,11 @@
1
+ """Helper output model for the explicit inability to answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from shikiru.domain.tasks import DomainModel
6
+
7
+
8
+ class Unrecoverable(DomainModel):
9
+ """The Helper's explicit inability to answer, for either call."""
10
+
11
+ unrecoverable: str
@@ -0,0 +1 @@
1
+ """FastMCP operator server."""
shikiru/mcp/server.py ADDED
@@ -0,0 +1,196 @@
1
+ """The stdio FastMCP adapter over the operator services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from shikiru.agents.contracts import parse_rung
11
+ from shikiru.config.settings import ComplexityName
12
+ from shikiru.domain.commands import (
13
+ AcceptResolution,
14
+ AnswerResolution,
15
+ CancelResolution,
16
+ Resolution,
17
+ ResolutionKind,
18
+ RetryResolution,
19
+ )
20
+ from shikiru.domain.events import Event, EventKind
21
+ from shikiru.domain.tasks import Mode, Role, Status
22
+ from shikiru.operator.commands import Ack
23
+ from shikiru.operator.events import MAX_WAIT_SECONDS
24
+ from shikiru.operator.queries import (
25
+ EscalationView,
26
+ PeekView,
27
+ StatusView,
28
+ TaskDetail,
29
+ TaskSummary,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from shikiru.operator.services import OperatorServices
34
+
35
+ SERVER_NAME = "shikiru"
36
+
37
+
38
+ class ResolutionInput(BaseModel):
39
+ """Typed MCP input for an escalation resolution."""
40
+
41
+ model_config = ConfigDict(extra="forbid", frozen=True)
42
+
43
+ kind: ResolutionKind
44
+ text: str | None = None
45
+ rung: str | None = None
46
+
47
+ def to_domain(self) -> Resolution:
48
+ """Convert this transport model to a typed domain resolution."""
49
+ if self.kind is ResolutionKind.ANSWER:
50
+ if self.text is None:
51
+ message = "answer resolution requires text"
52
+ raise ValueError(message)
53
+ return AnswerResolution(text=self.text)
54
+ if self.kind is ResolutionKind.RETRY:
55
+ return RetryResolution(rung=parse_rung(self.rung) if self.rung is not None else None)
56
+ if self.kind is ResolutionKind.ACCEPT:
57
+ return AcceptResolution()
58
+ return CancelResolution()
59
+
60
+
61
+ def build_server(services: OperatorServices) -> FastMCP:
62
+ """Build a typed FastMCP server over injected operator services."""
63
+ server = FastMCP(SERVER_NAME)
64
+
65
+ _register_query_tools(server, services)
66
+ _register_command_tools(server, services)
67
+ _register_session_tools(server, services)
68
+ _register_event_tools(server, services)
69
+ return server
70
+
71
+
72
+ def _register_query_tools(server: FastMCP, services: OperatorServices) -> None:
73
+ @server.tool()
74
+ def status(task_id: str | None = None) -> StatusView:
75
+ """Read engine, queue, usage and escalation status, optionally with one task's details."""
76
+ return services.queries.status(task_id)
77
+
78
+ @server.tool()
79
+ def list_tasks(statuses: list[Status] | None = None) -> list[TaskSummary]:
80
+ """List task summaries, optionally filtered by task status."""
81
+ return services.queries.list_tasks(statuses=statuses)
82
+
83
+ @server.tool()
84
+ def get_task(task_id: str) -> TaskDetail | None:
85
+ """Read one task's details, history, reports and sessions, or return null if absent."""
86
+ return services.queries.get_task(task_id)
87
+
88
+ @server.tool()
89
+ def list_escalations() -> list[EscalationView]:
90
+ """List all currently open escalations with their packets and structured decisions."""
91
+ return services.queries.list_escalations()
92
+
93
+ @server.tool()
94
+ def get_escalation(escalation_id: str) -> EscalationView | None:
95
+ """Read one open escalation with its packet and structured decisions, or null if absent."""
96
+ return services.queries.get_escalation(escalation_id)
97
+
98
+ @server.tool()
99
+ def peek_session(task_id: str, role: Role | None = None) -> PeekView | None:
100
+ """Read one task role's, or every role's, last message, usage and recent view lines."""
101
+ return services.queries.peek_session(task_id, role)
102
+
103
+
104
+ def _register_command_tools(server: FastMCP, services: OperatorServices) -> None:
105
+ @server.tool()
106
+ async def submit_task(
107
+ *,
108
+ request: str,
109
+ title: str | None = None,
110
+ mode: Mode | None = None,
111
+ complexity: ComplexityName | None = None,
112
+ priority: int = 0,
113
+ depends_on: list[str] | None = None,
114
+ leader: str | None = None,
115
+ implementer: str | None = None,
116
+ ) -> Ack:
117
+ """Queue a task with optional title, mode, complexity, priority, dependencies and rungs."""
118
+ return await services.commands.submit_task(
119
+ request,
120
+ title=title,
121
+ mode=mode,
122
+ complexity=complexity,
123
+ priority=priority,
124
+ depends_on=depends_on or (),
125
+ leader=parse_rung(leader) if leader is not None else None,
126
+ implementer=parse_rung(implementer) if implementer is not None else None,
127
+ )
128
+
129
+ @server.tool()
130
+ async def prioritize_task(task_id: str, priority: int) -> Ack:
131
+ """Set a queued task's dispatch priority."""
132
+ return await services.commands.prioritize_task(task_id, priority)
133
+
134
+ @server.tool()
135
+ async def pause_task(task_id: str, *, now: bool = False) -> Ack:
136
+ """Pause a task, optionally requesting an immediate pause."""
137
+ return await services.commands.pause_task(task_id, now=now)
138
+
139
+ @server.tool()
140
+ async def resume_task(task_id: str) -> Ack:
141
+ """Resume a paused task."""
142
+ return await services.commands.resume_task(task_id)
143
+
144
+ @server.tool()
145
+ async def cancel_task(task_id: str) -> Ack:
146
+ """Cancel a task."""
147
+ return await services.commands.cancel_task(task_id)
148
+
149
+ @server.tool()
150
+ async def retry_task(task_id: str) -> Ack:
151
+ """Retry a failed or escalated task from its current step."""
152
+ return await services.commands.retry_task(task_id)
153
+
154
+ @server.tool()
155
+ async def set_rung(task_id: str, role: Role, rung: str) -> Ack:
156
+ """Set the task role's agent, model and optional effort rung."""
157
+ return await services.commands.set_rung(task_id, role, parse_rung(rung))
158
+
159
+ @server.tool()
160
+ async def resolve_escalation(escalation_id: str, resolution: ResolutionInput) -> Ack:
161
+ """Resolve an open escalation with an answer, retry, accept or cancel decision."""
162
+ return await services.commands.resolve_escalation(escalation_id, resolution.to_domain())
163
+
164
+
165
+ def _register_session_tools(server: FastMCP, services: OperatorServices) -> None:
166
+ @server.tool()
167
+ async def send_message(task_id: str, role: Role, text: str) -> Ack:
168
+ """Queue a message the role reads at the start of its next turn."""
169
+ return await services.commands.send_message(task_id, role, text)
170
+
171
+ @server.tool()
172
+ async def take_over(task_id: str, role: Role) -> Ack:
173
+ """Stop the role's turn and run its session interactively in its pane."""
174
+ return await services.commands.take_over(task_id, role)
175
+
176
+ @server.tool()
177
+ async def hand_back(task_id: str, role: Role) -> Ack:
178
+ """Return a taken-over role's session to the engine and resume the task."""
179
+ return await services.commands.hand_back(task_id, role)
180
+
181
+
182
+ def _register_event_tools(server: FastMCP, services: OperatorServices) -> None:
183
+ @server.tool()
184
+ async def wait_for_events(
185
+ since_id: int,
186
+ kinds: list[EventKind] | None = None,
187
+ task_id: str | None = None,
188
+ timeout_s: float = MAX_WAIT_SECONDS,
189
+ ) -> list[Event]:
190
+ """Wait up to 300 seconds for events after since_id with optional kind and task filters."""
191
+ return await services.events.wait(
192
+ since_id,
193
+ kinds=kinds,
194
+ task=task_id,
195
+ timeout=timeout_s,
196
+ )
@@ -0,0 +1,33 @@
1
+ """Operator services shared by the CLI, MCP server and dashboard palette."""
2
+
3
+ from shikiru.operator.commands import Ack, AckStatus, OperatorCommands
4
+ from shikiru.operator.doctor import Doctor, DoctorCheck, DoctorReport
5
+ from shikiru.operator.events import EventFeed
6
+ from shikiru.operator.queries import (
7
+ EscalationView,
8
+ OperatorQueries,
9
+ OperatorQueryError,
10
+ PeekView,
11
+ StatusView,
12
+ TaskDetail,
13
+ TaskSummary,
14
+ )
15
+ from shikiru.operator.services import OperatorServices
16
+
17
+ __all__ = [
18
+ "Ack",
19
+ "AckStatus",
20
+ "Doctor",
21
+ "DoctorCheck",
22
+ "DoctorReport",
23
+ "EscalationView",
24
+ "EventFeed",
25
+ "OperatorCommands",
26
+ "OperatorQueries",
27
+ "OperatorQueryError",
28
+ "OperatorServices",
29
+ "PeekView",
30
+ "StatusView",
31
+ "TaskDetail",
32
+ "TaskSummary",
33
+ ]
@@ -0,0 +1,24 @@
1
+ """Shared admission policy for Shikiru-managed tmux client transitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.errors import UsageError
8
+
9
+ if TYPE_CHECKING:
10
+ from shikiru.tmux.client import Tmux
11
+
12
+ _BUSY_SESSION_MESSAGE = (
13
+ "a terminal is already attached to Shikiru session {session!r}; detach it first, then retry"
14
+ )
15
+
16
+
17
+ async def admit_attachment(tmux: Tmux, session: str, current_pane: str | None) -> bool:
18
+ """Return true for a same-client no-op or refuse when another client owns the session."""
19
+ clients = await tmux.session_client_panes(session)
20
+ if not clients:
21
+ return False
22
+ if len(clients) == 1 and current_pane is not None and clients[0] == current_pane:
23
+ return True
24
+ raise UsageError(_BUSY_SESSION_MESSAGE.format(session=session))