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,199 @@
1
+ """Pure dashboard timing rules for live task and quota views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum, auto
7
+ from math import ceil
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.config.settings import MAX_MOTION_REFRESH_HZ
11
+ from shikiru.domain.activity import is_stalled
12
+
13
+ if TYPE_CHECKING:
14
+ from datetime import datetime
15
+
16
+ from shikiru.config.settings import TimeoutsConfig
17
+
18
+ PULSE_PHASES = 2
19
+
20
+
21
+ class MotionStampKind(StrEnum):
22
+ """A task outcome that briefly stamps its comic strip."""
23
+
24
+ VERDICT = auto()
25
+ MERGED = auto()
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class MotionStamp:
30
+ """A verdict or merge event that may briefly stamp its task strip."""
31
+
32
+ at: datetime
33
+ kind: MotionStampKind
34
+ detail: str = ""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class TaskMotionState:
39
+ """Timing evidence for the task's current panel and most recent outcome."""
40
+
41
+ task_id: str
42
+ step_started_at: datetime
43
+ actual_step_started_at: datetime | None = None
44
+ turn_started_at: datetime | None = None
45
+ last_signal_at: datetime | None = None
46
+ last_signal: str | None = None
47
+ last_words: str | None = None
48
+ tool_running: bool = False
49
+ turn_active: bool = False
50
+ engine_driven: bool = False
51
+ held: bool = False
52
+ ended_at: datetime | None = None
53
+ stamp: MotionStamp | None = None
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class QuotaMotionState:
58
+ """One quota reset deadline rendered as a live countdown."""
59
+
60
+ agent: str
61
+ window: str
62
+ resets_at: datetime | None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class MotionState:
67
+ """The immutable inputs needed to render one dashboard frame."""
68
+
69
+ animations: bool
70
+ timeouts: TimeoutsConfig
71
+ refresh_hz: int
72
+ stamp_flash_seconds: float
73
+ escalation_pulse_hz: int
74
+ tasks: tuple[TaskMotionState, ...] = ()
75
+ quotas: tuple[QuotaMotionState, ...] = ()
76
+ escalations_open: bool = False
77
+ engine_running: bool = True
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class TaskMotionFrame:
82
+ """Timing output for one task strip, without presentation glyphs or colours."""
83
+
84
+ task_id: str
85
+ spinner_phase: int
86
+ spinner_visible: bool
87
+ signal_observed: bool
88
+ agent_processing: bool
89
+ stalled: bool
90
+ elapsed_seconds: int
91
+ actual_step_started_at: datetime | None
92
+ last_signal_at: datetime | None
93
+ last_signal: str | None
94
+ last_words: str | None
95
+ stamp_kind: MotionStampKind | None
96
+ stamp_detail: str
97
+ stamp_flash: bool
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class QuotaMotionFrame:
102
+ """Remaining seconds for one provider-reported quota reset."""
103
+
104
+ agent: str
105
+ window: str
106
+ remaining_seconds: int | None
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class MotionFrame:
111
+ """All time-derived values for a dashboard render at `now`."""
112
+
113
+ tasks: tuple[TaskMotionFrame, ...]
114
+ quotas: tuple[QuotaMotionFrame, ...]
115
+ escalation_red: bool
116
+
117
+
118
+ def frame(state: MotionState, now: datetime) -> MotionFrame:
119
+ """Calculate stream motion, outcome flashes, escalation pulse and live counters."""
120
+ return MotionFrame(
121
+ tasks=tuple(_task_frame(task, state, now) for task in state.tasks),
122
+ quotas=tuple(_quota_frame(quota, now) for quota in state.quotas),
123
+ escalation_red=(
124
+ state.escalations_open
125
+ and (
126
+ not state.animations
127
+ or int(now.timestamp() * state.escalation_pulse_hz * PULSE_PHASES) % PULSE_PHASES
128
+ == 0
129
+ )
130
+ ),
131
+ )
132
+
133
+
134
+ def is_in_progress_animated(
135
+ *, engine_running: bool, held: bool, stalled: bool, turn_active: bool, engine_driven: bool
136
+ ) -> bool:
137
+ """Whether a task is actively progressing: a live turn or the engine's own work, unheld."""
138
+ return engine_running and not held and not stalled and (turn_active or engine_driven)
139
+
140
+
141
+ def _task_frame(task: TaskMotionState, state: MotionState, now: datetime) -> TaskMotionFrame:
142
+ activity_at = task.last_signal_at or task.turn_started_at
143
+ stalled = (
144
+ task.turn_active
145
+ and activity_at is not None
146
+ and not task.held
147
+ and is_stalled(activity_at, task.tool_running, now, state.timeouts)
148
+ )
149
+ in_progress = is_in_progress_animated(
150
+ engine_running=state.engine_running,
151
+ held=task.held,
152
+ stalled=stalled,
153
+ turn_active=task.turn_active,
154
+ engine_driven=task.engine_driven,
155
+ )
156
+ signal_observed = in_progress and task.last_signal_at is not None
157
+ spinner_phase = 0
158
+ if in_progress and state.animations:
159
+ spinner_phase = int(now.timestamp() * min(state.refresh_hz, MAX_MOTION_REFRESH_HZ))
160
+ elapsed_end = task.ended_at or now
161
+ elapsed_seconds = max(int((elapsed_end - task.step_started_at).total_seconds()), 0)
162
+ stamp_kind = None
163
+ stamp_detail = ""
164
+ stamp_flash = False
165
+ if state.animations and task.stamp is not None:
166
+ age = (now - task.stamp.at).total_seconds()
167
+ if 0 <= age < state.stamp_flash_seconds:
168
+ stamp_kind = task.stamp.kind
169
+ stamp_detail = task.stamp.detail
170
+ stamp_flash = True
171
+ return TaskMotionFrame(
172
+ task_id=task.task_id,
173
+ spinner_phase=spinner_phase,
174
+ spinner_visible=in_progress and (signal_observed or task.engine_driven),
175
+ signal_observed=signal_observed,
176
+ agent_processing=in_progress,
177
+ stalled=stalled,
178
+ elapsed_seconds=elapsed_seconds,
179
+ actual_step_started_at=task.actual_step_started_at,
180
+ last_signal_at=task.last_signal_at,
181
+ last_signal=task.last_signal,
182
+ last_words=task.last_words,
183
+ stamp_kind=stamp_kind,
184
+ stamp_detail=stamp_detail,
185
+ stamp_flash=stamp_flash,
186
+ )
187
+
188
+
189
+ def _quota_frame(quota: QuotaMotionState, now: datetime) -> QuotaMotionFrame:
190
+ remaining = (
191
+ max(ceil((quota.resets_at - now).total_seconds()), 0)
192
+ if quota.resets_at is not None
193
+ else None
194
+ )
195
+ return QuotaMotionFrame(
196
+ agent=quota.agent,
197
+ window=quota.window,
198
+ remaining_seconds=remaining,
199
+ )
@@ -0,0 +1,377 @@
1
+ """Structured, toned rows for the reports and recent turn signals of an escalation packet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from pydantic import TypeAdapter, ValidationError
11
+
12
+ from shikiru.agents.contracts import (
13
+ Compacted,
14
+ QuotaReported,
15
+ SessionStarted,
16
+ UsageReported,
17
+ )
18
+ from shikiru.domain.reports import (
19
+ ConsultAnswer,
20
+ EscalationRequest,
21
+ FeedbackAnalysis,
22
+ FeedbackVerdict,
23
+ ImplementationReport,
24
+ NewTask,
25
+ PlanReport,
26
+ Report,
27
+ TriageReport,
28
+ Verdict,
29
+ )
30
+ from shikiru.domain.timeline_phrases import (
31
+ PHRASE_SEPARATOR,
32
+ SIGNAL_MESSAGE,
33
+ SIGNAL_TOOL_FINISHED,
34
+ SIGNAL_TOOL_STARTED,
35
+ SIGNAL_TURN_ENDED,
36
+ SIGNAL_TURN_FAILED,
37
+ Tone,
38
+ signal_phrase,
39
+ tool_target,
40
+ verdict_phrase,
41
+ )
42
+ from shikiru.view_log import VIEW_RUNG_KEY, VIEW_SIGNAL_KEY, VIEW_TIME_KEY, VIEW_TURN_KEY
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Sequence
46
+
47
+ from shikiru.domain.reports import Commit, Issue, Option, TestRun
48
+
49
+ _REPORT: TypeAdapter[Report] = TypeAdapter(Report | FeedbackVerdict)
50
+ _LABEL_OPEN = "**"
51
+ _LABEL_CLOSE = "**\n\n"
52
+ _BLOCK_SEPARATOR = "\n\n"
53
+ _LABEL_SEPARATOR = "-"
54
+ _SIGNAL_TIME_FORMAT = "%H:%M:%S"
55
+ _RECOMMENDED_STAR = "\N{BLACK STAR} "
56
+ _LOCATION_SEPARATOR = " @ "
57
+ _RUNG_SEPARATOR = " \N{MIDDLE DOT} "
58
+ _FIRST_LINE_SEPARATOR = "\n"
59
+ _OLD_SCHEMA_INDENT = 2
60
+ _REPORT_LABEL = "Report"
61
+ _TEXT_LABEL = "Text"
62
+ _LINE_LABEL = "Line"
63
+ _STATUS_TONES = {"completed": Tone.SUCCESS, "needs_decision": Tone.WARNING, "blocked": Tone.FAILURE}
64
+ _RESULT_TONES = {"passed": Tone.SUCCESS, "failed": Tone.FAILURE}
65
+ _SEVERITY_TONES = {"blocker": Tone.FAILURE, "major": Tone.WARNING}
66
+ _SIGNAL_KINDS = {
67
+ SIGNAL_MESSAGE: "message",
68
+ SIGNAL_TOOL_STARTED: "tool",
69
+ SIGNAL_TOOL_FINISHED: "tool done",
70
+ SIGNAL_TURN_ENDED: "turn end",
71
+ SIGNAL_TURN_FAILED: "failed",
72
+ UsageReported.__name__: "usage",
73
+ SessionStarted.__name__: "session",
74
+ QuotaReported.__name__: "quota",
75
+ Compacted.__name__: "compacted",
76
+ }
77
+ _REPORT_KINDS = {
78
+ TriageReport: "triage",
79
+ ImplementationReport: "implement",
80
+ PlanReport: "plan",
81
+ ConsultAnswer: "consult",
82
+ Verdict: "verify",
83
+ FeedbackAnalysis: "feedback analysis",
84
+ FeedbackVerdict: "feedback verify",
85
+ }
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Part:
90
+ """A run of text in one tone, optionally in the accent colour."""
91
+
92
+ text: str
93
+ tone: Tone = Tone.PLAIN
94
+ accent: bool = False
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class DetailRow:
99
+ """One labelled line of a details block; an empty label continues the row above."""
100
+
101
+ label: str
102
+ parts: tuple[Part, ...]
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class EvidenceEntry:
107
+ """One table row (lead, kind, one-line outcome) and the structured block that explains it."""
108
+
109
+ lead: str
110
+ kind: str
111
+ outcome: Part
112
+ details: tuple[DetailRow, ...]
113
+
114
+
115
+ def _raw(label: str, text: str) -> tuple[DetailRow, ...]:
116
+ return (DetailRow(label, (Part(text),)),)
117
+
118
+
119
+ def _row(label: str, text: str, tone: Tone = Tone.PLAIN) -> list[DetailRow]:
120
+ return [DetailRow(label, (Part(text, tone),))] if text else []
121
+
122
+
123
+ def _listed(label: str, items: Sequence[tuple[Part, ...]]) -> list[DetailRow]:
124
+ return [DetailRow(label if index == 0 else "", parts) for index, parts in enumerate(items)]
125
+
126
+
127
+ def _plain_items(label: str, items: Sequence[str]) -> list[DetailRow]:
128
+ return _listed(label, [(Part(item),) for item in items])
129
+
130
+
131
+ def _counted(count: int, noun: str) -> str:
132
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
133
+
134
+
135
+ def _commit(commit: Commit) -> tuple[Part, ...]:
136
+ return (Part(commit.sha, accent=True), Part(f" {commit.subject}"))
137
+
138
+
139
+ def _test(test: TestRun) -> tuple[Part, ...]:
140
+ parts = [Part(f"{test.name} "), Part(test.result, _RESULT_TONES.get(test.result, Tone.PLAIN))]
141
+ if test.details:
142
+ parts.append(Part(f" {test.details}"))
143
+ return tuple(parts)
144
+
145
+
146
+ def _issue(issue: Issue) -> tuple[Part, ...]:
147
+ parts = [
148
+ Part(issue.severity, _SEVERITY_TONES.get(issue.severity, Tone.PLAIN)),
149
+ Part(f" {issue.description}"),
150
+ ]
151
+ if issue.location:
152
+ parts.append(Part(f"{_LOCATION_SEPARATOR}{issue.location}"))
153
+ return tuple(parts)
154
+
155
+
156
+ def _option(option: Option) -> tuple[Part, ...]:
157
+ star = _RECOMMENDED_STAR if option.recommended else ""
158
+ return (
159
+ Part(f"{star}{option.label}", accent=option.recommended),
160
+ Part(f" {option.consequences}"),
161
+ )
162
+
163
+
164
+ def _new_task(task: NewTask) -> tuple[Part, ...]:
165
+ return (Part(task.title),)
166
+
167
+
168
+ def _escalation(request: EscalationRequest | None) -> list[DetailRow]:
169
+ if request is None:
170
+ return []
171
+ return [
172
+ *_row("Escalation question", request.question),
173
+ *_listed("Escalation options", [_option(option) for option in request.options]),
174
+ ]
175
+
176
+
177
+ def _implementation(report: ImplementationReport) -> tuple[Part, list[DetailRow]]:
178
+ tone = _STATUS_TONES[report.status]
179
+ status = report.status.replace("_", " ")
180
+ failed = sum(_RESULT_TONES.get(test.result) is Tone.FAILURE for test in report.tests)
181
+ bits = [status]
182
+ if report.commits:
183
+ bits.append(_counted(len(report.commits), "commit"))
184
+ if failed:
185
+ bits.append(f"{failed} failed")
186
+ return Part(PHRASE_SEPARATOR.join(bits), tone), [
187
+ *_row("Status", status, tone),
188
+ *_row("Summary", report.summary),
189
+ *_listed("Commits", [_commit(commit) for commit in report.commits]),
190
+ *_listed("Tests", [_test(test) for test in report.tests]),
191
+ *_plain_items("Risks", report.risks),
192
+ *_row("Question", report.question),
193
+ ]
194
+
195
+
196
+ def _feedback_analysis(report: FeedbackAnalysis) -> tuple[Part, list[DetailRow]]:
197
+ decisions = [
198
+ (
199
+ Part(decision.source_id, accent=True),
200
+ Part(f" {decision.action.value}: {decision.detail}"),
201
+ )
202
+ for decision in report.decisions
203
+ ]
204
+ return Part(report.summary), [
205
+ *_row("Summary", report.summary),
206
+ *_listed("Decisions", decisions),
207
+ *_plain_items("Work", [work.instruction for work in report.work]),
208
+ ]
209
+
210
+
211
+ def _verdict(report: Verdict) -> tuple[Part, list[DetailRow]]:
212
+ phrase = verdict_phrase(report.decision)
213
+ text, tone = (phrase.text, phrase.tone) if phrase is not None else (report.decision, Tone.PLAIN)
214
+ outcome = f"{text} ({_counted(len(report.issues), 'issue')})" if report.issues else text
215
+ return Part(outcome, tone), [
216
+ *_row("Decision", text, tone),
217
+ *_row("Summary", report.summary),
218
+ *_listed("Issues", [_issue(issue) for issue in report.issues]),
219
+ *_plain_items("Residual risks", report.residual_risks),
220
+ *_listed("New tasks", [_new_task(task) for task in report.new_tasks]),
221
+ *_plain_items("Scope changes", report.scope_changes),
222
+ *_escalation(report.escalation),
223
+ ]
224
+
225
+
226
+ def _triage(report: TriageReport) -> tuple[Part, list[DetailRow]]:
227
+ return Part(report.title), [
228
+ *_row("Title", report.title),
229
+ *_row("Mode", report.mode.value),
230
+ *_row("Complexity", report.complexity),
231
+ *_plain_items("Acceptance criteria", report.acceptance_criteria),
232
+ *_plain_items("References", report.references),
233
+ ]
234
+
235
+
236
+ def _plan(report: PlanReport) -> tuple[Part, list[DetailRow]]:
237
+ return Part(report.summary), [
238
+ *_row("Summary", report.summary),
239
+ *_listed("Commits", [_commit(commit) for commit in report.commits]),
240
+ *_listed("New tasks", [_new_task(task) for task in report.new_tasks]),
241
+ *_plain_items("Scope changes", report.scope_changes),
242
+ ]
243
+
244
+
245
+ def _consult(report: ConsultAnswer) -> tuple[Part, list[DetailRow]]:
246
+ tone = Tone.WARNING if report.escalation is not None else Tone.PLAIN
247
+ return Part(report.answer or report.decision, tone), [
248
+ *_row("Decision", report.decision, tone),
249
+ *_row("Answer", report.answer),
250
+ *_escalation(report.escalation),
251
+ ]
252
+
253
+
254
+ def _report_content(report: Report) -> tuple[Part, list[DetailRow]]:
255
+ match report:
256
+ case ImplementationReport():
257
+ return _implementation(report)
258
+ case Verdict():
259
+ return _verdict(report)
260
+ case TriageReport():
261
+ return _triage(report)
262
+ case PlanReport():
263
+ return _plan(report)
264
+ case ConsultAnswer():
265
+ return _consult(report)
266
+ case FeedbackAnalysis():
267
+ return _feedback_analysis(report)
268
+
269
+
270
+ def _first_line(text: str) -> str:
271
+ return text.strip().partition(_FIRST_LINE_SEPARATOR)[0]
272
+
273
+
274
+ def _readable_json(text: str) -> str:
275
+ try:
276
+ return json.dumps(json.loads(text), indent=_OLD_SCHEMA_INDENT, ensure_ascii=False)
277
+ except json.JSONDecodeError:
278
+ return text
279
+
280
+
281
+ def _label_parts(label: str) -> tuple[str, str]:
282
+ number, separator, rest = label.partition(_LABEL_SEPARATOR)
283
+ if not (separator and number.isdigit()):
284
+ return "", label
285
+ return number, rest.partition(_LABEL_SEPARATOR)[0]
286
+
287
+
288
+ def _report_entry(label: str, body: str) -> EvidenceEntry:
289
+ lead, step = _label_parts(label)
290
+ try:
291
+ report = _REPORT.validate_json(body)
292
+ except ValidationError:
293
+ return EvidenceEntry(
294
+ lead, step, Part(_first_line(body)), _raw(_REPORT_LABEL, _readable_json(body))
295
+ )
296
+ outcome, details = _report_content(report)
297
+ return EvidenceEntry(lead, step or _REPORT_KINDS[type(report)], outcome, tuple(details))
298
+
299
+
300
+ def _plain_entry(text: str) -> EvidenceEntry:
301
+ return EvidenceEntry("", "", Part(_first_line(text)), _raw(_TEXT_LABEL, text.strip()))
302
+
303
+
304
+ def parse_reports(content: str) -> tuple[EvidenceEntry, ...]:
305
+ """Read the packet's `**label**` + JSON report blocks; unreadable ones stay as readable text."""
306
+ entries: list[EvidenceEntry] = []
307
+ for block in content.split(_BLOCK_SEPARATOR + _LABEL_OPEN):
308
+ label, separator, body = block.removeprefix(_LABEL_OPEN).partition(_LABEL_CLOSE)
309
+ entries.append(_report_entry(label, body) if separator else _plain_entry(block))
310
+ return tuple(entries)
311
+
312
+
313
+ def _signal_time(value: object) -> str:
314
+ try:
315
+ return datetime.fromisoformat(str(value)).strftime(_SIGNAL_TIME_FORMAT)
316
+ except ValueError:
317
+ return ""
318
+
319
+
320
+ def _signal_details(signal: str, data: dict[str, object], tone: Tone) -> list[DetailRow]:
321
+ if signal == SIGNAL_MESSAGE:
322
+ return _row("Message", str(data.get("text") or ""))
323
+ if signal == SIGNAL_TURN_ENDED:
324
+ return _row("Last message", str(data.get("last_message") or ""))
325
+ if signal == SIGNAL_TOOL_STARTED:
326
+ return [
327
+ *_row("Tool", str(data.get("name") or "")),
328
+ *_row("Detail", tool_target(str(data.get("detail") or ""))),
329
+ ]
330
+ if signal == SIGNAL_TOOL_FINISHED:
331
+ exit_code = data.get("exit_code")
332
+ result = PHRASE_SEPARATOR.join(
333
+ str(part) for part in (data.get("outcome"), exit_code) if part is not None
334
+ )
335
+ return [*_row("Tool", str(data.get("name") or "")), *_row("Result", result, tone)]
336
+ if signal == SIGNAL_TURN_FAILED:
337
+ return [
338
+ *_row("Failure", str(data.get("kind") or ""), tone),
339
+ *_row("Message", str(data.get("message") or "")),
340
+ ]
341
+ return []
342
+
343
+
344
+ def _rung(value: object) -> str:
345
+ if not isinstance(value, dict):
346
+ return ""
347
+ rung = cast("dict[str, object]", value)
348
+ return _RUNG_SEPARATOR.join(
349
+ str(rung[key]) for key in ("agent", "model", "effort") if rung.get(key)
350
+ )
351
+
352
+
353
+ def _signal_entry(line: str) -> EvidenceEntry:
354
+ try:
355
+ decoded: object = json.loads(line)
356
+ except json.JSONDecodeError:
357
+ decoded = None
358
+ if not isinstance(decoded, dict) or VIEW_SIGNAL_KEY not in decoded:
359
+ return EvidenceEntry("", "", Part(line.strip()), _raw(_LINE_LABEL, line.strip()))
360
+ data = cast("dict[str, object]", decoded)
361
+ signal = str(data[VIEW_SIGNAL_KEY])
362
+ phrase = signal_phrase(signal, data)
363
+ tone = phrase.tone if phrase is not None else Tone.PLAIN
364
+ kind = _SIGNAL_KINDS.get(signal, signal)
365
+ details = [
366
+ *_row("Signal", kind),
367
+ *_row("Rung", _rung(data.get(VIEW_RUNG_KEY))),
368
+ *_row("Turn", str(data.get(VIEW_TURN_KEY) or "")),
369
+ *_signal_details(signal, data, tone),
370
+ ]
371
+ outcome = Part(phrase.text if phrase is not None else "", tone)
372
+ return EvidenceEntry(_signal_time(data.get(VIEW_TIME_KEY)), kind, outcome, tuple(details))
373
+
374
+
375
+ def parse_signals(content: str) -> tuple[EvidenceEntry, ...]:
376
+ """Read the packet's view.jsonl lines; a non-JSON or truncated line stays as a plain entry."""
377
+ return tuple(_signal_entry(line) for line in content.splitlines() if line.strip())
@@ -0,0 +1,47 @@
1
+ """Scheduling rules for pushing locally integrated base branches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class PushTarget:
11
+ """A base branch with integrations to publish and the scheduling facts of its pushes."""
12
+
13
+ base: str
14
+ registered: int
15
+ pushed: int
16
+ due_at: datetime | None
17
+ last_pushed_at: datetime | None
18
+
19
+ @property
20
+ def pending(self) -> bool:
21
+ """Return whether an integration was registered that no push has acknowledged."""
22
+ return self.registered > self.pushed
23
+
24
+ @property
25
+ def scheduled_at(self) -> datetime | None:
26
+ """Return when the pending integrations are due, or `None` when nothing is pending."""
27
+ return self.due_at if self.pending else None
28
+
29
+
30
+ def pending_deadline(*, last_pushed_at: datetime | None, now: datetime, interval: int) -> datetime:
31
+ """Return when work that becomes pending at `now` is due, given `interval` seconds.
32
+
33
+ Without an earlier push the interval starts now; after one, the work is due an interval
34
+ after it, or at once when that moment has passed. Zero means due at once.
35
+ """
36
+ if interval == 0:
37
+ return now
38
+ if last_pushed_at is None:
39
+ return now + timedelta(seconds=interval)
40
+ return max(last_pushed_at + timedelta(seconds=interval), now)
41
+
42
+
43
+ def is_due(target: PushTarget, *, now: datetime, interval: int) -> bool:
44
+ """Return whether `target` has pending work whose deadline has come."""
45
+ if not target.pending or target.due_at is None:
46
+ return False
47
+ return interval == 0 or now >= target.due_at
@@ -0,0 +1,44 @@
1
+ """The rule deciding when fresh quota evidence releases a provider quota block."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.agents.contracts import is_exhausted
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Sequence
11
+ from datetime import datetime
12
+
13
+ from shikiru.agents.contracts import QuotaWindow
14
+
15
+
16
+ def block_released(
17
+ *,
18
+ blocked_at: datetime,
19
+ stored: Sequence[QuotaWindow],
20
+ reading: Sequence[QuotaWindow],
21
+ now: datetime,
22
+ ) -> bool:
23
+ """Return whether `reading` proves the block set at `blocked_at` no longer holds.
24
+
25
+ Only samples observed after the block and no earlier than the stored sample of the same
26
+ window count. The fresh reading must have a usable window, show none exhausted and cover
27
+ every window `stored` holds exhausted, unless its known reset has already passed. A missing
28
+ reset does not show an exhausted window expired.
29
+ """
30
+ latest = {window.name: window.observed_at for window in stored}
31
+ fresh = [
32
+ window
33
+ for window in reading
34
+ if window.observed_at > blocked_at
35
+ and window.observed_at >= latest.get(window.name, window.observed_at)
36
+ ]
37
+ if not fresh or any(is_exhausted(window) for window in fresh):
38
+ return False
39
+ covered = {window.name for window in fresh}
40
+ return all(
41
+ window.name in covered
42
+ for window in stored
43
+ if is_exhausted(window) and (window.resets_at is None or window.resets_at > now)
44
+ )