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,174 @@
1
+ """Handlers for escalation resolution options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.commands import (
8
+ COMMAND_ID_KEY,
9
+ EVENT_TEXT_KEY,
10
+ AcceptResolution,
11
+ AnswerResolution,
12
+ CancelResolution,
13
+ ResolutionKind,
14
+ Resolve,
15
+ RetryResolution,
16
+ )
17
+ from shikiru.domain.escalation import EscalationStatus, transition_for_resolution
18
+ from shikiru.domain.events import EventKind
19
+ from shikiru.domain.steps import ACTING_ROLE, Transition
20
+ from shikiru.domain.tasks import EscalationReason, Status
21
+ from shikiru.engine.handlers.base import (
22
+ HandlerContext,
23
+ command_replayed,
24
+ finish_task_quietly,
25
+ interrupt_task,
26
+ kept_worktree_result,
27
+ refresh_titles,
28
+ require_command_id,
29
+ require_task,
30
+ )
31
+ from shikiru.errors import CommandRejectedError
32
+
33
+ if TYPE_CHECKING:
34
+ from shikiru.domain.escalation import Escalation
35
+ from shikiru.domain.tasks import Task
36
+
37
+
38
+ class ResolveHandler:
39
+ """Apply `answer`, `retry`, `accept` or `cancel` to an open escalation."""
40
+
41
+ command_type: type[Resolve] = Resolve
42
+
43
+ def __init__(self, context: HandlerContext) -> None:
44
+ self._context = context
45
+
46
+ async def handle(self, command: Resolve) -> dict[str, object]:
47
+ if command_replayed(self._context, EventKind.ESCALATION_RESOLVED, command):
48
+ escalation = self._escalation(command.escalation_id)
49
+ else:
50
+ escalation = self._open_escalation(command)
51
+ await self._apply(command, escalation)
52
+ task = require_task(self._context, escalation.task_id)
53
+ await refresh_titles(self._context, task.id)
54
+ kept = (
55
+ await finish_task_quietly(self._context, task)
56
+ if isinstance(command.resolution, CancelResolution)
57
+ else None
58
+ )
59
+ return {
60
+ "escalation_id": escalation.id,
61
+ "task_id": task.id,
62
+ "status": task.status.value,
63
+ **kept_worktree_result(kept),
64
+ }
65
+
66
+ async def _apply(self, command: Resolve, escalation: Escalation) -> None:
67
+ task = require_task(self._context, escalation.task_id)
68
+ if task.status is not Status.ESCALATED:
69
+ message = (
70
+ f"{escalation.id} no longer applies: {task.id} is {task.status.value}, "
71
+ "not escalated"
72
+ )
73
+ raise CommandRejectedError(message)
74
+ transition = self._transition(task, escalation, command)
75
+ if not isinstance(command.resolution, AnswerResolution):
76
+ await interrupt_task(self._context, task, even_if_inactive=True)
77
+ with self._context.atomically():
78
+ self._apply_transition(task, escalation, command, transition)
79
+ self._context.escalations.resolve(escalation.id, self._resolution_text(command))
80
+ self._context.events.append(
81
+ EventKind.ESCALATION_RESOLVED,
82
+ task_id=task.id,
83
+ message=f"{escalation.id} resolved with {command.resolution.kind.value}",
84
+ data={
85
+ "escalation_id": escalation.id,
86
+ "resolution": command.resolution.kind.value,
87
+ **self._answer_text(command),
88
+ COMMAND_ID_KEY: require_command_id(command),
89
+ },
90
+ )
91
+
92
+ def _escalation(self, escalation_id: str) -> Escalation:
93
+ escalation = self._context.escalations.get(escalation_id)
94
+ if escalation is None:
95
+ message = f"escalation {escalation_id} does not exist"
96
+ raise CommandRejectedError(message)
97
+ return escalation
98
+
99
+ def _open_escalation(self, command: Resolve) -> Escalation:
100
+ escalation = self._escalation(command.escalation_id)
101
+ if escalation.status is not EscalationStatus.OPEN:
102
+ message = f"escalation {command.escalation_id} is already resolved"
103
+ raise CommandRejectedError(message)
104
+ if command.resolution.kind not in {option.label for option in escalation.options}:
105
+ message = (
106
+ f"resolution {command.resolution.kind.value} is not valid for "
107
+ f"{escalation.reason.value}"
108
+ )
109
+ raise CommandRejectedError(message)
110
+ return escalation
111
+
112
+ def _transition(self, task: Task, escalation: Escalation, command: Resolve) -> Transition:
113
+ acting_role = ACTING_ROLE.get(escalation.step) if escalation.step is not None else None
114
+ rung = command.resolution.rung if isinstance(command.resolution, RetryResolution) else None
115
+ try:
116
+ return transition_for_resolution(
117
+ task,
118
+ escalation.reason,
119
+ step=escalation.step,
120
+ resolution=command.resolution.kind,
121
+ rung=rung,
122
+ role=acting_role,
123
+ )
124
+ except ValueError as error:
125
+ raise CommandRejectedError(str(error)) from error
126
+
127
+ def _apply_transition(
128
+ self,
129
+ task: Task,
130
+ escalation: Escalation,
131
+ command: Resolve,
132
+ transition: Transition,
133
+ ) -> None:
134
+ if (
135
+ isinstance(command.resolution, RetryResolution)
136
+ and escalation.reason is EscalationReason.DEPENDENCY_CANCELLED
137
+ ):
138
+ self._drop_cancelled_dependencies(task.id, task.depends_on)
139
+ if transition.status is Status.CANCELLED:
140
+ self._context.tasks.finish(
141
+ task.id,
142
+ transition,
143
+ EventKind.TASK_CANCELLED,
144
+ message=f"{task.id} cancelled",
145
+ data={"reason": "cancelled by operator"},
146
+ )
147
+ return
148
+ self._context.tasks.apply_transition(task.id, transition)
149
+ self._context.tasks.set_requeued(task.id)
150
+
151
+ def _drop_cancelled_dependencies(self, task_id: str, dependencies: list[str]) -> None:
152
+ keep: list[str] = []
153
+ for dependency_id in dependencies:
154
+ dependency = self._context.tasks.get(dependency_id)
155
+ if dependency is not None and dependency.status is not Status.CANCELLED:
156
+ keep.append(dependency_id)
157
+ self._context.tasks.set_depends_on(task_id, keep)
158
+
159
+ @staticmethod
160
+ def _answer_text(command: Resolve) -> dict[str, str]:
161
+ resolution = command.resolution
162
+ return {EVENT_TEXT_KEY: resolution.text} if isinstance(resolution, AnswerResolution) else {}
163
+
164
+ @staticmethod
165
+ def _resolution_text(command: Resolve) -> str:
166
+ resolution = command.resolution
167
+ if isinstance(resolution, AnswerResolution):
168
+ return f"{ResolutionKind.ANSWER.value} {resolution.text}"
169
+ if isinstance(resolution, RetryResolution):
170
+ rung = resolution.rung
171
+ return "retry" + (f" {rung.agent}:{rung.model}" if rung else "")
172
+ if isinstance(resolution, AcceptResolution):
173
+ return "accept"
174
+ return "cancel"
@@ -0,0 +1,81 @@
1
+ """Handlers for the Operator's direct access to a task's agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.commands import COMMAND_ID_KEY, EVENT_TEXT_KEY, HandBack, Say, TakeOver
8
+ from shikiru.domain.events import EventKind
9
+ from shikiru.engine.handlers.base import (
10
+ HandlerContext,
11
+ reject_terminal,
12
+ require_command_id,
13
+ require_task,
14
+ )
15
+ from shikiru.workspace.messages import command_message_key
16
+
17
+ if TYPE_CHECKING:
18
+ from shikiru.engine.takeover import Takeover
19
+ from shikiru.workspace.messages import OperatorMessages
20
+
21
+
22
+ class SayHandler:
23
+ """Queue an Operator message for a role's next turn without holding the task."""
24
+
25
+ command_type: type[Say] = Say
26
+
27
+ def __init__(self, context: HandlerContext, messages: OperatorMessages) -> None:
28
+ self._context = context
29
+ self._messages = messages
30
+
31
+ async def handle(self, command: Say) -> dict[str, object]:
32
+ task = require_task(self._context, command.task_id)
33
+ reject_terminal(task)
34
+ command_id = require_command_id(command)
35
+ path = self._messages.queue(
36
+ task.id, command.role, command.text, command_message_key(command_id)
37
+ )
38
+ if not self._context.events.command_applied(EventKind.OPERATOR_MESSAGE, command_id):
39
+ self._context.events.append(
40
+ EventKind.OPERATOR_MESSAGE,
41
+ task_id=task.id,
42
+ message=f"{task.id} message queued for {command.role.value}",
43
+ data={
44
+ "role": command.role.value,
45
+ "path": str(path),
46
+ EVENT_TEXT_KEY: command.text,
47
+ COMMAND_ID_KEY: command_id,
48
+ },
49
+ )
50
+ return {"task_id": task.id, "role": command.role.value, "path": str(path)}
51
+
52
+
53
+ class TakeOverHandler:
54
+ """Hand a role's session to the Operator in its pane, holding only that task."""
55
+
56
+ command_type: type[TakeOver] = TakeOver
57
+
58
+ def __init__(self, context: HandlerContext, takeover: Takeover) -> None:
59
+ self._context = context
60
+ self._takeover = takeover
61
+
62
+ async def handle(self, command: TakeOver) -> dict[str, object]:
63
+ task = require_task(self._context, command.task_id)
64
+ reject_terminal(task)
65
+ await self._takeover.take_over(task, command.role, require_command_id(command))
66
+ return {"task_id": task.id, "role": command.role.value}
67
+
68
+
69
+ class HandBackHandler:
70
+ """Return a taken-over role's session to the engine."""
71
+
72
+ command_type: type[HandBack] = HandBack
73
+
74
+ def __init__(self, context: HandlerContext, takeover: Takeover) -> None:
75
+ self._context = context
76
+ self._takeover = takeover
77
+
78
+ async def handle(self, command: HandBack) -> dict[str, object]:
79
+ task = require_task(self._context, command.task_id)
80
+ await self._takeover.hand_back(task, command.role, require_command_id(command))
81
+ return {"task_id": task.id, "role": command.role.value}
@@ -0,0 +1,237 @@
1
+ """Handlers for task lifecycle and scheduling commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+
7
+ from shikiru.domain.commands import (
8
+ COMMAND_ID_KEY,
9
+ Cancel,
10
+ Pause,
11
+ Prioritize,
12
+ ResolutionKind,
13
+ Resume,
14
+ Retry,
15
+ SetRung,
16
+ Submit,
17
+ )
18
+ from shikiru.domain.events import EventKind
19
+ from shikiru.domain.ladder import reset_for_rung_change
20
+ from shikiru.domain.steps import Transition
21
+ from shikiru.domain.tasks import Hold, Role, Status
22
+ from shikiru.engine.handlers.base import (
23
+ HandlerContext,
24
+ command_replayed,
25
+ finish_task_quietly,
26
+ interrupt_task,
27
+ kept_worktree_result,
28
+ refresh_titles,
29
+ reject_terminal,
30
+ require_command_id,
31
+ require_task,
32
+ )
33
+ from shikiru.errors import CommandRejectedError
34
+
35
+
36
+ class SubmitHandler:
37
+ """Insert a submitted task and emit its queue event."""
38
+
39
+ command_type: type[Submit] = Submit
40
+
41
+ def __init__(self, context: HandlerContext) -> None:
42
+ self._context = context
43
+
44
+ async def handle(self, command: Submit) -> dict[str, object]:
45
+ task = self._context.tasks.get(command.task_id) if command.task_id is not None else None
46
+ if task is None:
47
+ with self._context.atomically():
48
+ task = self._context.tasks.insert_from_submit(command)
49
+ self._context.events.append(
50
+ EventKind.TASK_QUEUED,
51
+ task_id=task.id,
52
+ message=f"{task.id} queued",
53
+ data={"priority": task.priority},
54
+ )
55
+ return {"task_id": task.id}
56
+
57
+
58
+ class PrioritizeHandler:
59
+ """Change one queued task's dispatch priority."""
60
+
61
+ command_type: type[Prioritize] = Prioritize
62
+
63
+ def __init__(self, context: HandlerContext) -> None:
64
+ self._context = context
65
+
66
+ async def handle(self, command: Prioritize) -> dict[str, object]:
67
+ task = require_task(self._context, command.task_id)
68
+ if task.status is not Status.QUEUED:
69
+ message = f"{task.id} can only be prioritized while queued"
70
+ raise CommandRejectedError(message)
71
+ self._context.tasks.set_priority(task.id, command.priority)
72
+ return {"task_id": task.id, "priority": command.priority}
73
+
74
+
75
+ class PauseHandler:
76
+ """Set a task pause hold, optionally interrupting its current turn."""
77
+
78
+ command_type: type[Pause] = Pause
79
+
80
+ def __init__(self, context: HandlerContext) -> None:
81
+ self._context = context
82
+
83
+ async def handle(self, command: Pause) -> dict[str, object]:
84
+ task = require_task(self._context, command.task_id)
85
+ reject_terminal(task)
86
+ if command.now:
87
+ await interrupt_task(self._context, task)
88
+ if Hold.PAUSED not in task.holds:
89
+ with self._context.atomically():
90
+ self._context.tasks.set_holds(task.id, task.holds | {Hold.PAUSED})
91
+ self._context.events.append(
92
+ EventKind.HOLD_SET,
93
+ task_id=task.id,
94
+ message=f"{task.id} paused",
95
+ data={"hold": Hold.PAUSED.value, "now": command.now},
96
+ )
97
+ await refresh_titles(self._context, task.id)
98
+ return {"task_id": task.id, "paused": True, "now": command.now}
99
+
100
+
101
+ class ResumeHandler:
102
+ """Clear task holds and resume the task flow, unless a role is taken over."""
103
+
104
+ command_type: type[Resume] = Resume
105
+
106
+ def __init__(self, context: HandlerContext) -> None:
107
+ self._context = context
108
+
109
+ async def handle(self, command: Resume) -> dict[str, object]:
110
+ task = require_task(self._context, command.task_id)
111
+ reject_terminal(task)
112
+ if self._context.runs.has_taken_over(task.id):
113
+ message = f"{task.id} has a role taken over; hand it back before resuming"
114
+ raise CommandRejectedError(message)
115
+ holds = task.holds
116
+ self._context.tasks.set_holds(task.id, frozenset())
117
+ for hold in holds:
118
+ self._context.events.append(
119
+ EventKind.HOLD_CLEARED,
120
+ task_id=task.id,
121
+ message=f"{task.id} {hold.value} hold cleared",
122
+ data={"hold": hold.value},
123
+ )
124
+ if holds & {Hold.PAUSED, Hold.INTERVENTION} and task.status in (
125
+ Status.RUNNING,
126
+ Status.INTEGRATING,
127
+ ):
128
+ await self._context.start_task(task.id)
129
+ await refresh_titles(self._context, task.id)
130
+ return {"task_id": task.id, "paused": False, "intervention": False}
131
+
132
+
133
+ class CancelHandler:
134
+ """Cancel a task, interrupting its current turn and retaining its branch."""
135
+
136
+ command_type: type[Cancel] = Cancel
137
+
138
+ def __init__(self, context: HandlerContext) -> None:
139
+ self._context = context
140
+
141
+ async def handle(self, command: Cancel) -> dict[str, object]:
142
+ task = require_task(self._context, command.task_id)
143
+ if not command_replayed(self._context, EventKind.TASK_CANCELLED, command):
144
+ reject_terminal(task)
145
+ await interrupt_task(self._context, task)
146
+ current = require_task(self._context, task.id)
147
+ with self._context.atomically():
148
+ self._context.tasks.finish(
149
+ task.id,
150
+ Transition.stay(current, step=current.step, status=Status.CANCELLED),
151
+ EventKind.TASK_CANCELLED,
152
+ message=f"{task.id} cancelled",
153
+ data={
154
+ "reason": "cancelled by operator",
155
+ COMMAND_ID_KEY: require_command_id(command),
156
+ },
157
+ )
158
+ self._context.escalations.settle_open(task.id, ResolutionKind.CANCEL.value)
159
+ updated = require_task(self._context, task.id)
160
+ await refresh_titles(self._context, updated.id)
161
+ kept = await finish_task_quietly(self._context, updated)
162
+ return {
163
+ "task_id": task.id,
164
+ "status": Status.CANCELLED.value,
165
+ **kept_worktree_result(kept),
166
+ }
167
+
168
+
169
+ class RetryHandler:
170
+ """Cancel the current step and requeue it for an idempotent retry."""
171
+
172
+ command_type: type[Retry] = Retry
173
+
174
+ def __init__(self, context: HandlerContext) -> None:
175
+ self._context = context
176
+
177
+ async def handle(self, command: Retry) -> dict[str, object]:
178
+ task = require_task(self._context, command.task_id)
179
+ if not command_replayed(self._context, EventKind.TASK_RETRIED, command):
180
+ reject_terminal(task)
181
+ await interrupt_task(self._context, task)
182
+ current = require_task(self._context, task.id)
183
+ counters = current.counters.model_copy(update={"consults": 0, "conflict_rounds": 0})
184
+ transition = dataclasses.replace(
185
+ Transition.stay(current, step=current.step, status=Status.QUEUED),
186
+ counters=counters,
187
+ )
188
+ with self._context.atomically():
189
+ self._context.tasks.apply_transition(current.id, transition)
190
+ self._context.tasks.set_requeued(current.id)
191
+ self._context.escalations.settle_open(current.id, ResolutionKind.RETRY.value)
192
+ self._context.events.append(
193
+ EventKind.TASK_RETRIED,
194
+ task_id=current.id,
195
+ message=f"{current.id} retried",
196
+ data={COMMAND_ID_KEY: require_command_id(command)},
197
+ )
198
+ await refresh_titles(self._context, task.id)
199
+ return {"task_id": task.id, "status": require_task(self._context, task.id).status.value}
200
+
201
+
202
+ class SetRungHandler:
203
+ """Apply a role rung override to the next task turn."""
204
+
205
+ command_type: type[SetRung] = SetRung
206
+
207
+ def __init__(self, context: HandlerContext) -> None:
208
+ self._context = context
209
+
210
+ async def handle(self, command: SetRung) -> dict[str, object]:
211
+ task = require_task(self._context, command.task_id)
212
+ reject_terminal(task)
213
+ old = task.overrides.get(command.role)
214
+ if old != command.rung:
215
+ counters = task.counters
216
+ if command.role is Role.IMPLEMENTER:
217
+ counters = reset_for_rung_change(counters)
218
+ transition = dataclasses.replace(
219
+ Transition.stay(task, step=task.step, status=task.status),
220
+ counters=counters,
221
+ set_overrides={command.role: command.rung},
222
+ )
223
+ with self._context.atomically():
224
+ self._context.tasks.apply_transition(task.id, transition)
225
+ self._context.events.append(
226
+ EventKind.RUNG_CHANGED,
227
+ task_id=task.id,
228
+ message=f"{task.id} {command.role.value} rung set to {command.rung.model}",
229
+ data={
230
+ "role": command.role.value,
231
+ "from": old.model if old is not None else None,
232
+ "to": command.rung.model,
233
+ "operator": True,
234
+ },
235
+ )
236
+ await refresh_titles(self._context, task.id)
237
+ return {"task_id": task.id, "role": command.role.value, "model": command.rung.model}
@@ -0,0 +1,128 @@
1
+ """Read and select the reports recorded for a task's turns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.domain.reports import (
9
+ DECISION_ACCEPT,
10
+ ConsultAnswer,
11
+ FeedbackVerdict,
12
+ ImplementationReport,
13
+ PlanReport,
14
+ Report,
15
+ Verdict,
16
+ )
17
+ from shikiru.engine.integration import IntegrationMetadata
18
+ from shikiru.engine.turns import read_turn_report
19
+ from shikiru.paths import turns_dir
20
+ from shikiru.view_log import ordered_turn_dirs
21
+ from shikiru.workspace.reports import ENGINE_VERDICT_FILENAME as _ENGINE_VERDICT_FILENAME
22
+ from shikiru.workspace.reports import (
23
+ LEGACY_COMMIT_SUBJECT,
24
+ InvalidTurnReportError,
25
+ load_engine_verdict,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Sequence
30
+ from pathlib import Path
31
+
32
+ from shikiru.domain.tasks import Task
33
+
34
+ _LOGGER = logging.getLogger(__name__)
35
+ ENGINE_VERDICT_FILENAME = _ENGINE_VERDICT_FILENAME
36
+ FALLBACK_COMMIT_TYPE = "chore"
37
+
38
+
39
+ class ReportHistory:
40
+ """Load reports in turn order and derive the contexts used by task flows."""
41
+
42
+ def __init__(self, project_root: Path) -> None:
43
+ """Read reports from the project's task run directories."""
44
+ self._project_root = project_root
45
+
46
+ def load(self, task_id: str) -> list[tuple[str, Report]]:
47
+ """Load valid reports for `task_id`, logging and omitting invalid reports."""
48
+ turns_root = turns_dir(self._project_root, task_id)
49
+ if not turns_root.is_dir():
50
+ return []
51
+ history: list[tuple[str, Report]] = []
52
+ for turn_dir in ordered_turn_dirs(turns_root):
53
+ try:
54
+ report = read_turn_report(turn_dir)
55
+ except InvalidTurnReportError:
56
+ _LOGGER.exception("invalid report in %s", turn_dir)
57
+ report = None
58
+ if report is not None:
59
+ history.append((turn_dir.name, report))
60
+ try:
61
+ engine_verdict = load_engine_verdict(turn_dir)
62
+ except InvalidTurnReportError:
63
+ _LOGGER.exception("invalid engine verdict in %s", turn_dir)
64
+ engine_verdict = None
65
+ if engine_verdict is not None:
66
+ history.append(
67
+ (
68
+ turn_dir.name,
69
+ engine_verdict,
70
+ )
71
+ )
72
+ return history
73
+
74
+ @staticmethod
75
+ def context(history: Sequence[tuple[str, Report]]) -> list[dict[str, str]]:
76
+ """Serialize reports into the ordered prompt and escalation context."""
77
+ return [{"label": label, "json": report.model_dump_json()} for label, report in history]
78
+
79
+ @staticmethod
80
+ def latest_question(history: Sequence[tuple[str, Report]]) -> str:
81
+ """Return the Implementer question behind the latest consult, if it has one.
82
+
83
+ The Leader's answers and escalations follow the question's report, so they are skipped.
84
+ """
85
+ for _, report in reversed(history):
86
+ if isinstance(report, ConsultAnswer):
87
+ continue
88
+ return report.question if isinstance(report, ImplementationReport) else ""
89
+ return ""
90
+
91
+ def latest_report(self, task_id: str) -> Report | None:
92
+ """Return the newest valid report for `task_id`."""
93
+ history = self.load(task_id)
94
+ return history[-1][1] if history else None
95
+
96
+ def integration_metadata(self, task: Task) -> IntegrationMetadata:
97
+ """Choose the accepted change's subject and summary, falling back to the latest plan.
98
+
99
+ A task with neither gets a `chore` subject named after its title, summarized by it.
100
+ """
101
+ history = self.load(task.id)
102
+ fallback_subject = f"{FALLBACK_COMMIT_TYPE}: {' '.join(task.title.split())}"
103
+ for _, report in reversed(history):
104
+ if (
105
+ isinstance(report, Verdict)
106
+ and not isinstance(report, FeedbackVerdict)
107
+ and report.decision == DECISION_ACCEPT
108
+ ):
109
+ return IntegrationMetadata(
110
+ subject=(
111
+ fallback_subject
112
+ if report.commit_subject == LEGACY_COMMIT_SUBJECT
113
+ else report.commit_subject
114
+ ),
115
+ summary=report.summary,
116
+ residual_risks=tuple(report.residual_risks),
117
+ )
118
+ for _, report in reversed(history):
119
+ if isinstance(report, PlanReport):
120
+ return IntegrationMetadata(
121
+ subject=(
122
+ fallback_subject
123
+ if report.commit_subject == LEGACY_COMMIT_SUBJECT
124
+ else report.commit_subject
125
+ ),
126
+ summary=report.summary,
127
+ )
128
+ return IntegrationMetadata(subject=fallback_subject, summary=task.title)