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,402 @@
1
+ """Bounded projections of recorded run lineages and their turn identities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, TypeGuard, cast
7
+
8
+ from shikiru.agents.contracts import (
9
+ COST_BASIS_API_EQUIVALENT,
10
+ COST_BASIS_BILLED,
11
+ COST_BASIS_UNKNOWN,
12
+ )
13
+ from shikiru.dashboard.contract import (
14
+ TaskHistoryGap,
15
+ TaskModelHistoryView,
16
+ TaskSessionView,
17
+ TaskTurnUsageDeltaView,
18
+ )
19
+ from shikiru.domain.events import Event, EventKind
20
+ from shikiru.domain.runs import Run, RunStatus, has_positive_token_evidence
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Sequence
24
+
25
+ EFFORT_FIELD = "effort"
26
+ TURN_IDENTITY_EFFORT_RECORDED_INDEX = 3
27
+ MODEL_HISTORY_STATUS_COMPLETED = "completed"
28
+ MODEL_HISTORY_STATUS_FAILED = "failed"
29
+ MODEL_HISTORY_STATUS_INTERRUPTED = "interrupted"
30
+ MODEL_HISTORY_STATUS_IDENTITY_UNCERTAIN = "identity uncertain"
31
+ TURN_COST_BASES = (
32
+ COST_BASIS_BILLED,
33
+ COST_BASIS_API_EQUIVALENT,
34
+ COST_BASIS_UNKNOWN,
35
+ )
36
+ _HISTORY_EVENTS = {
37
+ EventKind.TURN_COMPLETED: MODEL_HISTORY_STATUS_COMPLETED,
38
+ EventKind.TURN_FAILED: MODEL_HISTORY_STATUS_FAILED,
39
+ EventKind.TURN_STOPPED: MODEL_HISTORY_STATUS_INTERRUPTED,
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class _TurnHistory:
45
+ run_id: int
46
+ agent: str
47
+ model: str
48
+ effort: str | None
49
+ turn: str | None
50
+ status: str
51
+ started: bool
52
+ ended: bool
53
+ usage_delta: TaskTurnUsageDeltaView | None
54
+ identity_complete: bool
55
+
56
+
57
+ class _TaskSessionHistory:
58
+ def __init__(
59
+ self,
60
+ runs: Sequence[Run],
61
+ *,
62
+ history_truncated: bool,
63
+ history_error: bool,
64
+ ) -> None:
65
+ self._runs = runs
66
+ self._runs_by_id = {run.id: run for run in runs}
67
+ self._history_truncated = history_truncated
68
+ self._history_error = history_error
69
+ self._history: dict[int, list[_TurnHistory]] = {run.id: [] for run in runs}
70
+ self._gaps: dict[int, list[TaskHistoryGap]] = {run.id: [] for run in runs}
71
+ self._unlinked_roles: set[str] = set()
72
+ self._turns: dict[tuple[str, str], _TurnHistory] = {}
73
+
74
+ def project(self, events: Sequence[Event]) -> tuple[TaskSessionView, ...]:
75
+ """Return run rows decorated with bounded model observations and explicit gaps."""
76
+ for event in reversed(events):
77
+ self._append(event)
78
+ self._finish_gaps()
79
+ return tuple(
80
+ self._session_view(run, run.status in (RunStatus.LIVE, RunStatus.TAKEN_OVER))
81
+ for run in self._runs
82
+ )
83
+
84
+ def _append(self, event: Event) -> None:
85
+ role = _event_string(event, "role")
86
+ turn = _event_string(event, "turn")
87
+ identity = _turn_identity(event, role)
88
+ paired = self._turns.get((role, turn)) if role is not None and turn is not None else None
89
+ if event.kind is EventKind.TURN_STARTED:
90
+ self._start(event, role, turn, identity)
91
+ elif event.kind in _HISTORY_EVENTS:
92
+ self._end(event, role, turn, identity, paired)
93
+
94
+ def _start(
95
+ self,
96
+ event: Event,
97
+ role: str | None,
98
+ turn: str | None,
99
+ identity: tuple[str, str, str | None, bool] | None,
100
+ ) -> None:
101
+ run = self._run_for(event, role, identity)
102
+ if run is None or identity is None:
103
+ self._mark_unlinked(role)
104
+ return
105
+ agent, model, effort, effort_recorded = identity
106
+ history = _TurnHistory(
107
+ run_id=run.id,
108
+ agent=agent,
109
+ model=model,
110
+ effort=effort,
111
+ turn=turn,
112
+ status=MODEL_HISTORY_STATUS_INTERRUPTED,
113
+ started=True,
114
+ ended=False,
115
+ usage_delta=None,
116
+ identity_complete=effort_recorded,
117
+ )
118
+ self._history[run.id].append(history)
119
+ if turn is not None:
120
+ self._turns[(role or "", turn)] = history
121
+ if not effort_recorded:
122
+ self._gaps[run.id].append(TaskHistoryGap.MISSING_RECORDED_EFFORT)
123
+
124
+ def _end(
125
+ self,
126
+ event: Event,
127
+ role: str | None,
128
+ turn: str | None,
129
+ identity: tuple[str, str, str | None, bool] | None,
130
+ paired: _TurnHistory | None,
131
+ ) -> None:
132
+ if paired is not None and _event_run_matches(event, paired.run_id):
133
+ self._finish_pair(event, paired, identity)
134
+ return
135
+ self._finish_unpaired(event, role, turn, identity)
136
+
137
+ def _finish_pair(
138
+ self,
139
+ event: Event,
140
+ paired: _TurnHistory,
141
+ identity: tuple[str, str, str | None, bool] | None,
142
+ ) -> None:
143
+ end_state = _HISTORY_EVENTS[event.kind]
144
+ paired.ended = True
145
+ if identity is not None and not _identity_matches(paired, identity):
146
+ paired.status = MODEL_HISTORY_STATUS_IDENTITY_UNCERTAIN
147
+ self._gaps[paired.run_id].append(TaskHistoryGap.INCONSISTENT_IDENTITY)
148
+ self._record_end_identity(event, identity, end_state)
149
+ return
150
+ paired.status = end_state
151
+ if event.kind is EventKind.TURN_COMPLETED:
152
+ if (
153
+ not paired.identity_complete
154
+ or identity is None
155
+ or not identity[TURN_IDENTITY_EFFORT_RECORDED_INDEX]
156
+ ):
157
+ self._gaps[paired.run_id].append(TaskHistoryGap.INCOMPLETE_IDENTITY)
158
+ else:
159
+ paired.usage_delta = _turn_usage_delta(event)
160
+ if paired.usage_delta is None:
161
+ self._gaps[paired.run_id].append(TaskHistoryGap.MISSING_USAGE_DELTA)
162
+
163
+ def _record_end_identity(
164
+ self,
165
+ event: Event,
166
+ identity: tuple[str, str, str | None, bool],
167
+ status: str,
168
+ ) -> None:
169
+ run = self._run_for(event, _event_string(event, "role"), identity)
170
+ if run is None:
171
+ return
172
+ agent, model, effort, effort_recorded = identity
173
+ self._history[run.id].append(
174
+ _TurnHistory(
175
+ run_id=run.id,
176
+ agent=agent,
177
+ model=model,
178
+ effort=effort,
179
+ turn=_event_string(event, "turn"),
180
+ status=status,
181
+ started=False,
182
+ ended=True,
183
+ usage_delta=None,
184
+ identity_complete=effort_recorded,
185
+ )
186
+ )
187
+ self._gaps[run.id].append(TaskHistoryGap.INCONSISTENT_IDENTITY)
188
+
189
+ def _finish_unpaired(
190
+ self,
191
+ event: Event,
192
+ role: str | None,
193
+ turn: str | None,
194
+ identity: tuple[str, str, str | None, bool] | None,
195
+ ) -> None:
196
+ run = self._run_for(event, role, identity)
197
+ if run is None or identity is None:
198
+ self._mark_unlinked(role)
199
+ return
200
+ agent, model, effort, effort_recorded = identity
201
+ delta = _turn_usage_delta(event) if event.kind is EventKind.TURN_COMPLETED else None
202
+ self._history[run.id].append(
203
+ _TurnHistory(
204
+ run_id=run.id,
205
+ agent=agent,
206
+ model=model,
207
+ effort=effort,
208
+ turn=turn,
209
+ status=_HISTORY_EVENTS[event.kind],
210
+ started=False,
211
+ ended=True,
212
+ usage_delta=delta,
213
+ identity_complete=effort_recorded,
214
+ )
215
+ )
216
+ self._gaps[run.id].append(TaskHistoryGap.MISSING_TURN_START)
217
+ if not effort_recorded:
218
+ self._gaps[run.id].append(TaskHistoryGap.MISSING_RECORDED_EFFORT)
219
+ if event.kind is EventKind.TURN_COMPLETED and delta is None:
220
+ self._gaps[run.id].append(TaskHistoryGap.MISSING_USAGE_DELTA)
221
+
222
+ def _run_for(
223
+ self,
224
+ event: Event,
225
+ role: str | None,
226
+ identity: tuple[str, str, str | None, bool] | None,
227
+ ) -> Run | None:
228
+ run_id = event.data.get("run_id")
229
+ if isinstance(run_id, bool):
230
+ return None
231
+ if isinstance(run_id, int):
232
+ run = self._runs_by_id.get(run_id)
233
+ return run if run is not None and _run_matches(run, role, identity) else None
234
+ if run_id is not None or role is None or identity is None:
235
+ return None
236
+ candidates = [
237
+ run
238
+ for run in self._runs
239
+ if run.role.value == role
240
+ and run.agent == identity[0]
241
+ and run.started_at <= event.ts
242
+ and (run.ended_at is None or event.ts <= run.ended_at)
243
+ ]
244
+ return candidates[0] if len(candidates) == 1 else None
245
+
246
+ def _mark_unlinked(self, role: str | None) -> None:
247
+ if role is not None:
248
+ self._unlinked_roles.add(role)
249
+
250
+ def _finish_gaps(self) -> None:
251
+ for run in self._runs:
252
+ gaps = self._gaps[run.id]
253
+ if self._history_truncated:
254
+ gaps.append(TaskHistoryGap.TRUNCATED)
255
+ if self._history_error:
256
+ gaps.append(TaskHistoryGap.READ_FAILED)
257
+ if run.role.value in self._unlinked_roles:
258
+ gaps.append(TaskHistoryGap.UNLINKED_TURN)
259
+ for history in self._history[run.id]:
260
+ if history.started and not history.ended:
261
+ gaps.append(TaskHistoryGap.UNENDED_TURN)
262
+ if not self._history[run.id] and _run_has_turn_evidence(run):
263
+ gaps.append(TaskHistoryGap.MISSING_HISTORY)
264
+
265
+ def _session_view(self, run: Run, current_lineage: bool) -> TaskSessionView:
266
+ return TaskSessionView(
267
+ role=run.role.value,
268
+ agent=run.agent,
269
+ model=run.model,
270
+ effort=run.effort,
271
+ session_id=run.session_id,
272
+ status=run.status.value,
273
+ input_tokens=run.usage.input_tokens,
274
+ output_tokens=run.usage.output_tokens,
275
+ cache_read_tokens=run.usage.cache_read_tokens,
276
+ cache_write_tokens=run.usage.cache_write_tokens,
277
+ cost_usd=run.usage.cost_usd,
278
+ cost_basis=run.usage.cost_basis,
279
+ context_used_pct=run.usage.context_used_pct,
280
+ billing_mode=run.billing_mode,
281
+ billing_source=run.billing_source,
282
+ usage_reported=run.usage_reported,
283
+ legacy_token_totals=(run.usage_reported is None and has_positive_token_evidence(run)),
284
+ legacy_cost_recorded=(run.usage_reported is None and run.usage.cost_usd is not None),
285
+ run_id=run.id,
286
+ current_lineage=current_lineage,
287
+ model_history=tuple(_history_view(item) for item in self._history[run.id]),
288
+ history_gaps=tuple(dict.fromkeys(self._gaps[run.id])),
289
+ )
290
+
291
+
292
+ def project_task_sessions(
293
+ runs: Sequence[Run],
294
+ events: Sequence[Event],
295
+ *,
296
+ history_truncated: bool,
297
+ history_error: bool,
298
+ ) -> tuple[TaskSessionView, ...]:
299
+ """Project persisted session lineages and link bounded model history when evidence permits."""
300
+ return _TaskSessionHistory(
301
+ runs,
302
+ history_truncated=history_truncated,
303
+ history_error=history_error,
304
+ ).project(events)
305
+
306
+
307
+ def _run_matches(
308
+ run: Run,
309
+ role: str | None,
310
+ identity: tuple[str, str, str | None, bool] | None,
311
+ ) -> bool:
312
+ return (role is None or run.role.value == role) and (
313
+ identity is None or run.agent == identity[0]
314
+ )
315
+
316
+
317
+ def _event_run_matches(event: Event, run_id: int) -> bool:
318
+ event_run_id = event.data.get("run_id")
319
+ return event_run_id is None or (not isinstance(event_run_id, bool) and event_run_id == run_id)
320
+
321
+
322
+ def _identity_matches(
323
+ history: _TurnHistory,
324
+ identity: tuple[str, str, str | None, bool],
325
+ ) -> bool:
326
+ agent, model, effort, effort_recorded = identity
327
+ if history.agent != agent or history.model != model:
328
+ return False
329
+ return not history.identity_complete or not effort_recorded or history.effort == effort
330
+
331
+
332
+ def _turn_identity(
333
+ event: Event,
334
+ role: str | None,
335
+ ) -> tuple[str, str, str | None, bool] | None:
336
+ agent = _event_string(event, "agent")
337
+ model = _event_string(event, "model")
338
+ if role is None or agent is None or model is None:
339
+ return None
340
+ effort = event.data.get(EFFORT_FIELD)
341
+ if not isinstance(effort, str | None):
342
+ return None
343
+ return agent, model, effort, EFFORT_FIELD in event.data
344
+
345
+
346
+ def _event_string(event: Event, key: str) -> str | None:
347
+ value = event.data.get(key)
348
+ return value if isinstance(value, str) else None
349
+
350
+
351
+ def _turn_usage_delta(event: Event) -> TaskTurnUsageDeltaView | None:
352
+ raw = event.data.get("usage_delta")
353
+ if not isinstance(raw, dict):
354
+ return None
355
+ usage_data = cast("dict[str, object]", raw)
356
+ input_tokens = usage_data.get("input_tokens")
357
+ output_tokens = usage_data.get("output_tokens")
358
+ cache_read_tokens = usage_data.get("cache_read_tokens")
359
+ cache_write_tokens = usage_data.get("cache_write_tokens")
360
+ if not (
361
+ _is_token_count(input_tokens)
362
+ and _is_token_count(output_tokens)
363
+ and _is_token_count(cache_read_tokens)
364
+ and _is_token_count(cache_write_tokens)
365
+ ):
366
+ return None
367
+ cost = usage_data.get("cost_usd")
368
+ if cost is not None and (isinstance(cost, bool) or not isinstance(cost, int | float)):
369
+ return None
370
+ cost_basis = usage_data.get("cost_basis", COST_BASIS_UNKNOWN)
371
+ if not isinstance(cost_basis, str) or cost_basis not in TURN_COST_BASES:
372
+ cost_basis = COST_BASIS_UNKNOWN
373
+ return TaskTurnUsageDeltaView(
374
+ input_tokens=input_tokens,
375
+ output_tokens=output_tokens,
376
+ cache_read_tokens=cache_read_tokens,
377
+ cache_write_tokens=cache_write_tokens,
378
+ cost_usd=float(cost) if cost is not None else None,
379
+ cost_basis=cost_basis,
380
+ )
381
+
382
+
383
+ def _is_token_count(value: object) -> TypeGuard[int]:
384
+ return not isinstance(value, bool) and isinstance(value, int)
385
+
386
+
387
+ def _history_view(history: _TurnHistory) -> TaskModelHistoryView:
388
+ return TaskModelHistoryView(
389
+ agent=history.agent,
390
+ model=history.model,
391
+ effort=history.effort,
392
+ turn=history.turn,
393
+ status=history.status,
394
+ usage_delta=history.usage_delta,
395
+ identity_complete=history.identity_complete,
396
+ )
397
+
398
+
399
+ def _run_has_turn_evidence(run: Run) -> bool:
400
+ return (
401
+ run.session_id is not None or run.usage_reported is True or has_positive_token_evidence(run)
402
+ )
@@ -0,0 +1,40 @@
1
+ 
2
+ 
3
+ 
4
+ 
5
+ 
6
+ ⣀⣤⣶⣶⣿⣿⣿⣿⣿⣷⣶⣦⣄⡀ 
7
+ ⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⡀ 
8
+ ⣀⠠⣴⣲⣾⣿⣷⣶⣶⣶⣦⣤⣤⣀⣀ ⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄ 
9
+ ⡠⢒⣭⣶⣿⣿⣿⡿⠿⠿⠿⠿⢛⣿⣛⠿⠿⣿⣿⣶⣤⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧ 
10
+ ⣠⣴⣵⣾⣿⣿⠿⠛⠉⠁ ⠒⠭⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇ 
11
+ ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡀ ⢀⣼⣿⣿⣿⣿⡿⠁ ⠙⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀ 
12
+ ⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟ ⣰⣿⣿⣿⣿⡵⠋ ⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ 
13
+ ⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣨⣿⣿⣿⣿⣯⡀ ⣼⣿⣿⣿⢯⠎ ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇ 
14
+ ⠘⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⠿⠿⠿⠟ ⣰⣿⣿⣿⢯⠏ ⢠⣤⣄ ⣤⣤⡀ ⢀⣠⣤⣀ ⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿ 
15
+ ⣠⣤⣤⣿⣿⣿⣿⣿⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⡀ ⣿⣿⣿⣿⡎ ⢸⣿⣿⡇ ⣀⣨⣿⣷⣿⠦ ⢲⣾⣿⣿⣿⠿ ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃ 
16
+ ⠻⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠋ ⢸⡟⣿⣿⣿⠁ ⣼⣿⡟ ⠙⠻⠛⣿⣥⣴⣦ ⢨⣾⡟⠁ ⢹⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁ ⢀⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤ 
17
+ ⢸⡇⣿⣿⣿ ⣿⣿⠁ ⢠⣤⣶⣿⣿⣟⠁ ⣰⣿⣿⣾⠿⢿⣶⣄ ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋ ⠈⠛⠛⠛⠛⠛⢻⣿⣿⣿⣿⠟⠛⠛⠛⠋ 
18
+ ⠈ ⣿⣿⣿⡀ ⢰⣿⣿ ⢀⡄ ⠉⠉⠠⣼⣿⣧ ⣾⣿⠟⠉ ⣿⣿⡄ ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋ ⢰⣶⣿⣿⣿⣿⣿⣷⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶ 
19
+ ⢆⢸⣿⣿⣇ ⠘⣿⣿⣀⣀⣤⣶⡿ ⣾⣄ ⠉⠁ ⠙⠋⣴⡿⢿⣦⣠⣿⣿⠁ ⣾⠉⠙⠛⠛⠛⠛⠛⠛⠉⠁ ⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁ 
20
+ ⠘⣆⣿⣿⣧⡀ ⠙⣿⣿⣿⣿⠟⠁ ⠙⢿⣿⣿⣷ ⠻⣶⣶⣿⣿⠟⠁ ⢰⠇ ⠰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡷ 
21
+ ⠘⣿⣿⣿⣷⡀ ⠉⠉ ⠉⠉ ⠉ ⠁ ⢀⣾⢄⠏ ⠈⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉ 
22
+ ⢀⣀⣀ ⠈⢻⣿⣿⣿⣮⣢⡀ ⣠⣿⠏ ⢀⣴⣿⣿⣿⣿⣿⣶⡀ 
23
+ ⣠⣴⣶⣿⣿⣿⣿⣿⣷⣦⡀⠙⢿⣿⣿⣿⣿⣶⣄⡀ ⢀⣠⣾⣿⠟ ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄ 
24
+ ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣭⣤⣄⠙⠻⢿⣿⣿⣿⣿⣿⣶⣤⣤⣀⣀⣀⡀ ⣀⣤⣶⣿⣿⠟⠁ ⣀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⡀ 
25
+ ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣣⣀ ⠉⠛⠿⣿⣿⣿⠿⢿⣿⠿⠷ ⠤⣤⣶⣿⡿⠟⠋ ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀ 
26
+ ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠛⠈⣿⣿⢿⣿⣷ ⠈⠉⠙⠒⠒⠒⠒⠒⠒⠛⠋⠉ ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⡀ 
27
+ ⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁ ⠘⠿⠋⢠⣽⡿ ⣠⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄ 
28
+ ⣠⣴⣾⣿⣿⣦ ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇ ⠉ ⣠⣴⣶⣿⣿⣿⣷⣦⣄ ⣀⣤⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⡀ 
29
+ ⣠⣾⣿⣿⣿⣿⣿⣿⢿⡆⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆ ⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣃⣀⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣤⣀ 
30
+ ⣠⣤⣤⡄ ⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣉⠁⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣄⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣎⠉⠛⠿⠿⠿⠿⠛⠛⢛⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⡀ 
31
+ ⣴⣾⣿⣿⣿⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠉⢹⣿⣤⠸⠿ ⣀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣍⡙⠛⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⡀ 
32
+ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣀⠉⠁ ⣀⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠻ ⠈⠉⠙⠛⢛⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣂ ⠉⠉⠉⠙⠉⣉⣭⣭⣭⡉⠙⠛⠒
33
+ ⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣄⣀⣀⣀⣀⣠⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠇ ⢀⣀⣤⣶⣿⣿⣿⣿⣿⣿⣃ 
34
+ ⠈⠉⠛⠛⠿⠿⠿⣿⣿⡿⠿⠿⠿⠛⠛⠛⠛⠛⠛⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠛⠛⠛⠛⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠛⠛⠛⠛⠿⢿⣿⣿⣿
35
+ ⠉⠙⠛⠿⠿⢿⣿⣿⣿⣭⣭⠥⠴⠖ ⠈⠙⠛⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⢿⣿⣿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠷⠛⠛ 
36
+ ⠉⠉⠉⠛⠛⠛⠛⠛⠉⠉⠉ ⠈⠉⠉⠉⠉ ⠈⠉⠉⠉⠉⠉⠉⠉⠉⠁ 
37
+ 
38
+ 
39
+ 
40
+