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,262 @@
1
+ """Pure Docker container parsing, scoping and ranking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from pathlib import PurePath
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Mapping, Sequence
13
+
14
+ DOCKER_TOP_COUNT = 5
15
+ PS_ID_FIELD = "ID"
16
+ PS_NAMES_FIELD = "Names"
17
+ PS_STATE_FIELD = "State"
18
+ PS_STATUS_FIELD = "Status"
19
+ PS_LABELS_FIELD = "Labels"
20
+ STATS_ID_FIELD = "ID"
21
+ STATS_NAME_FIELD = "Name"
22
+ STATS_CPU_FIELD = "CPUPerc"
23
+ STATS_MEMORY_FIELD = "MemUsage"
24
+ NOT_RUNNING_READING = "--"
25
+ COMPOSE_WORKING_DIR_LABEL = "com.docker.compose.project.working_dir"
26
+ STATE_RUNNING = "running"
27
+ STATE_RESTARTING = "restarting"
28
+ STATE_EXITED = "exited"
29
+ UNHEALTHY_MARKER = "(unhealthy)"
30
+ NAME_SEPARATOR = ","
31
+ LABEL_SEPARATOR = ","
32
+ LABEL_ASSIGNMENT = "="
33
+ MEMORY_SEPARATOR = "/"
34
+ PERCENT_SUFFIX = "%"
35
+ BYTES_PER_KILO = 1000
36
+ BYTES_PER_KIBI = 1024
37
+ BYTES_PER_MEBIBYTE = BYTES_PER_KIBI**2
38
+ MEMORY_UNIT_BYTES = {
39
+ "B": 1,
40
+ "kB": BYTES_PER_KILO,
41
+ "KB": BYTES_PER_KILO,
42
+ "MB": BYTES_PER_KILO**2,
43
+ "GB": BYTES_PER_KILO**3,
44
+ "TB": BYTES_PER_KILO**4,
45
+ "KiB": BYTES_PER_KIBI,
46
+ "MiB": BYTES_PER_MEBIBYTE,
47
+ "GiB": BYTES_PER_KIBI**3,
48
+ "TiB": BYTES_PER_KIBI**4,
49
+ }
50
+ _MEMORY_READING = re.compile(r"(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>[A-Za-z]+)")
51
+ _EXIT_STATUS = re.compile(r"Exited \((?P<code>\d+)\)")
52
+
53
+
54
+ class DockerParseError(ValueError):
55
+ """A Docker record does not match the documented CLI output."""
56
+
57
+
58
+ class ProblemKind(StrEnum):
59
+ """Why a container needs attention."""
60
+
61
+ UNHEALTHY = "unhealthy"
62
+ RESTARTING = "restarting"
63
+ EXITED = "exited"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ContainerProblem:
68
+ """One container that is unhealthy, restarting or exited non-zero."""
69
+
70
+ name: str
71
+ kind: ProblemKind
72
+ exit_code: int | None = None
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ContainerInfo:
77
+ """One `docker ps -a` record reduced to the facts the dashboard uses."""
78
+
79
+ id: str
80
+ name: str
81
+ running: bool
82
+ problem: ContainerProblem | None
83
+ working_dir: str | None
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ContainerUsage:
88
+ """One running container's CPU and memory as numbers."""
89
+
90
+ id: str
91
+ name: str
92
+ cpu_pct: float
93
+ memory_bytes: int
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ElsewhereTotals:
98
+ """Running containers outside the scope; usage is `None` when unavailable."""
99
+
100
+ running: int
101
+ cpu_pct: float | None
102
+ memory_bytes: int | None
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class DockerSummary:
107
+ """Scoped counts, usage totals, problems, the CPU ranking and the rest of the host."""
108
+
109
+ running: int
110
+ total: int
111
+ cpu_pct: float | None
112
+ memory_bytes: int | None
113
+ problems: tuple[ContainerProblem, ...]
114
+ top: tuple[ContainerUsage, ...]
115
+ elsewhere: ElsewhereTotals
116
+ usage_available: bool
117
+
118
+ @property
119
+ def is_empty(self) -> bool:
120
+ """Whether the host has no scoped container and no running one elsewhere."""
121
+ return not self.total and not self.elsewhere.running
122
+
123
+
124
+ def parse_cpu_percent(text: str) -> float | None:
125
+ """Return the percentage in `1.23%`, or `None` for the not-running reading."""
126
+ stripped = text.strip()
127
+ if stripped == NOT_RUNNING_READING:
128
+ return None
129
+ try:
130
+ return float(stripped.removesuffix(PERCENT_SUFFIX))
131
+ except ValueError as error:
132
+ message = f"docker CPU reading is not a percentage: {text!r}"
133
+ raise DockerParseError(message) from error
134
+
135
+
136
+ def parse_memory_usage(text: str) -> int | None:
137
+ """Return the used bytes in `12MiB / 1.9GiB`, or `None` for the not-running reading."""
138
+ used = text.partition(MEMORY_SEPARATOR)[0].strip()
139
+ if used == NOT_RUNNING_READING:
140
+ return None
141
+ match = _MEMORY_READING.fullmatch(used)
142
+ if match is None or match["unit"] not in MEMORY_UNIT_BYTES:
143
+ message = f"docker memory reading is not a size: {text!r}"
144
+ raise DockerParseError(message)
145
+ return round(float(match["value"]) * MEMORY_UNIT_BYTES[match["unit"]])
146
+
147
+
148
+ def parse_labels(text: str) -> dict[str, str]:
149
+ """Return the `k=v,k=v` label string as a mapping; fragments without `=` are skipped."""
150
+ labels: dict[str, str] = {}
151
+ for fragment in text.split(LABEL_SEPARATOR):
152
+ key, assignment, value = fragment.partition(LABEL_ASSIGNMENT)
153
+ if assignment:
154
+ labels[key] = value
155
+ return labels
156
+
157
+
158
+ def parse_ps_record(record: Mapping[str, object]) -> ContainerInfo:
159
+ """Return a `docker ps -a --format json` record as a `ContainerInfo`."""
160
+ name = _text(record, PS_NAMES_FIELD).split(NAME_SEPARATOR)[0]
161
+ state = _text(record, PS_STATE_FIELD)
162
+ status = _text(record, PS_STATUS_FIELD)
163
+ working_dir = parse_labels(_text(record, PS_LABELS_FIELD)).get(COMPOSE_WORKING_DIR_LABEL)
164
+ return ContainerInfo(
165
+ id=_text(record, PS_ID_FIELD),
166
+ name=name,
167
+ running=state == STATE_RUNNING,
168
+ problem=_problem(name, state, status),
169
+ working_dir=working_dir,
170
+ )
171
+
172
+
173
+ def parse_stats_record(record: Mapping[str, object]) -> ContainerUsage | None:
174
+ """Return a `docker stats` record as numbers, or `None` while it has no reading yet."""
175
+ cpu_pct = parse_cpu_percent(_text(record, STATS_CPU_FIELD))
176
+ memory_bytes = parse_memory_usage(_text(record, STATS_MEMORY_FIELD))
177
+ if cpu_pct is None or memory_bytes is None:
178
+ return None
179
+ return ContainerUsage(
180
+ id=_text(record, STATS_ID_FIELD),
181
+ name=_text(record, STATS_NAME_FIELD),
182
+ cpu_pct=cpu_pct,
183
+ memory_bytes=memory_bytes,
184
+ )
185
+
186
+
187
+ def rank_by_cpu(usages: Sequence[ContainerUsage], limit: int) -> tuple[ContainerUsage, ...]:
188
+ """Return the `limit` busiest containers: CPU descending, then memory descending, then name."""
189
+ ranked = sorted(usages, key=lambda usage: (-usage.cpu_pct, -usage.memory_bytes, usage.name))
190
+ return tuple(ranked[:limit])
191
+
192
+
193
+ def in_project(info: ContainerInfo, project_roots: Sequence[PurePath]) -> bool:
194
+ """Return whether the container's compose working directory is inside a project root."""
195
+ if info.working_dir is None:
196
+ return False
197
+ working_dir = PurePath(info.working_dir)
198
+ return any(working_dir.is_relative_to(root) for root in project_roots)
199
+
200
+
201
+ def summarize(
202
+ containers: Sequence[ContainerInfo],
203
+ usages: Sequence[ContainerUsage] | None,
204
+ project_roots: Sequence[PurePath] | None,
205
+ ) -> DockerSummary:
206
+ """Summarize the scoped containers; `None` usages mean unavailable, `None` roots mean all."""
207
+ scoped = [c for c in containers if project_roots is None or in_project(c, project_roots)]
208
+ scoped_ids = {c.id for c in scoped}
209
+ other_running = [c for c in containers if c.running and c.id not in scoped_ids]
210
+ problems = tuple(c.problem for c in scoped if c.problem is not None)
211
+ running = [c for c in scoped if c.running]
212
+ if usages is None:
213
+ return DockerSummary(
214
+ running=len(running),
215
+ total=len(scoped),
216
+ cpu_pct=None,
217
+ memory_bytes=None,
218
+ problems=problems,
219
+ top=(),
220
+ elsewhere=ElsewhereTotals(len(other_running), None, None),
221
+ usage_available=False,
222
+ )
223
+ usage_by_id = {usage.id: usage for usage in usages}
224
+ scoped_usage = [usage_by_id[c.id] for c in running if c.id in usage_by_id]
225
+ other_usage = [usage_by_id[c.id] for c in other_running if c.id in usage_by_id]
226
+ return DockerSummary(
227
+ running=len(running),
228
+ total=len(scoped),
229
+ cpu_pct=sum(usage.cpu_pct for usage in scoped_usage),
230
+ memory_bytes=sum(usage.memory_bytes for usage in scoped_usage),
231
+ problems=problems,
232
+ top=rank_by_cpu(scoped_usage, DOCKER_TOP_COUNT),
233
+ elsewhere=ElsewhereTotals(
234
+ len(other_running),
235
+ sum(usage.cpu_pct for usage in other_usage),
236
+ sum(usage.memory_bytes for usage in other_usage),
237
+ ),
238
+ usage_available=True,
239
+ )
240
+
241
+
242
+ def _text(record: Mapping[str, object], field: str) -> str:
243
+ value = record.get(field)
244
+ if not isinstance(value, str):
245
+ message = f"docker record field {field} is missing or not a string"
246
+ raise DockerParseError(message)
247
+ return value
248
+
249
+
250
+ def _problem(name: str, state: str, status: str) -> ContainerProblem | None:
251
+ if state == STATE_RESTARTING:
252
+ return ContainerProblem(name, ProblemKind.RESTARTING)
253
+ if state == STATE_RUNNING and UNHEALTHY_MARKER in status:
254
+ return ContainerProblem(name, ProblemKind.UNHEALTHY)
255
+ if state != STATE_EXITED:
256
+ return None
257
+ match = _EXIT_STATUS.match(status)
258
+ if match is None:
259
+ message = f"docker exited status has no exit code: {status!r}"
260
+ raise DockerParseError(message)
261
+ code = int(match["code"])
262
+ return ContainerProblem(name, ProblemKind.EXITED, code) if code != 0 else None
@@ -0,0 +1,87 @@
1
+ """Pure projections of recorded engine start and stop evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.domain.events import Event, EventKind
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Sequence
12
+ from datetime import datetime
13
+
14
+ ENGINE_PID_FIELD = "engine_pid"
15
+ ENGINE_PROCESS_STARTED_AT_FIELD = "engine_process_started_at"
16
+ ENGINE_LIFECYCLE_PAIR_SIZE = 2
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class EngineUptime:
21
+ running_seconds: float | None
22
+ previous_run_seconds: float | None
23
+
24
+
25
+ def project_engine_uptime(
26
+ events: Sequence[Event],
27
+ *,
28
+ now: datetime,
29
+ running: bool,
30
+ identity: tuple[int, float] | None,
31
+ ) -> EngineUptime:
32
+ """Return live or previous-run duration only when lifecycle evidence is consistent."""
33
+ ordered_events = tuple(sorted(events, key=lambda event: event.id))
34
+ if running:
35
+ return EngineUptime(
36
+ running_seconds=_running_duration(ordered_events, now, identity),
37
+ previous_run_seconds=None,
38
+ )
39
+ return EngineUptime(
40
+ running_seconds=None,
41
+ previous_run_seconds=_previous_duration(ordered_events, now),
42
+ )
43
+
44
+
45
+ def _running_duration(
46
+ events: Sequence[Event], now: datetime, identity: tuple[int, float] | None
47
+ ) -> float | None:
48
+ if identity is None or not events:
49
+ return None
50
+ latest = events[-1]
51
+ if (
52
+ latest.kind is not EventKind.ENGINE_STARTED
53
+ or _event_identity(latest) != identity
54
+ or latest.ts > now
55
+ ):
56
+ return None
57
+ seconds = (now - latest.ts).total_seconds()
58
+ return seconds if seconds >= 0 else None
59
+
60
+
61
+ def _previous_duration(events: Sequence[Event], now: datetime) -> float | None:
62
+ if len(events) < ENGINE_LIFECYCLE_PAIR_SIZE:
63
+ return None
64
+ started, stopped = events[len(events) - ENGINE_LIFECYCLE_PAIR_SIZE :]
65
+ if (
66
+ started.kind is not EventKind.ENGINE_STARTED
67
+ or stopped.kind is not EventKind.ENGINE_STOPPED
68
+ or _event_identity(started) is None
69
+ or _event_identity(started) != _event_identity(stopped)
70
+ or started.ts > stopped.ts
71
+ or stopped.ts > now
72
+ ):
73
+ return None
74
+ return (stopped.ts - started.ts).total_seconds()
75
+
76
+
77
+ def _event_identity(event: Event) -> tuple[int, float] | None:
78
+ pid = event.data.get(ENGINE_PID_FIELD)
79
+ started_at = event.data.get(ENGINE_PROCESS_STARTED_AT_FIELD)
80
+ if (
81
+ isinstance(pid, bool)
82
+ or not isinstance(pid, int)
83
+ or isinstance(started_at, bool)
84
+ or not isinstance(started_at, int | float)
85
+ ):
86
+ return None
87
+ return pid, float(started_at)
@@ -0,0 +1,194 @@
1
+ """Escalation options and their recommended default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import StrEnum, auto
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.domain.commands import ResolutionKind
11
+ from shikiru.domain.ladder import reset_for_integration_attempt, reset_for_rung_change
12
+ from shikiru.domain.steps import Transition
13
+ from shikiru.domain.tasks import Counters, DomainModel, EscalationReason, Role, Status, Step, Task
14
+
15
+ if TYPE_CHECKING:
16
+ from shikiru.agents.contracts import Rung
17
+
18
+
19
+ ESCALATION_REASON_LABELS: dict[EscalationReason, str] = {
20
+ EscalationReason.DECISION_REQUIRED: "Decision required",
21
+ EscalationReason.ITERATIONS_EXHAUSTED: "Iterations exhausted",
22
+ EscalationReason.INTEGRATION_CONFLICT: "Integration conflict",
23
+ EscalationReason.INTEGRATION_BLOCKED: "Integration blocked",
24
+ EscalationReason.UNCOMMITTED_WORK: "Uncommitted work",
25
+ EscalationReason.OFF_TASK_BRANCH: "Off task branch",
26
+ EscalationReason.PR_CLOSED: "Pull request closed",
27
+ EscalationReason.AGENT_AUTH: "Agent authentication",
28
+ EscalationReason.AGENT_ERROR: "Agent error",
29
+ EscalationReason.AGENT_STUCK: "Agent stuck",
30
+ EscalationReason.AGENT_PROTOCOL: "Agent protocol error",
31
+ EscalationReason.LADDER_EXHAUSTED: "Ladder exhausted",
32
+ EscalationReason.QUOTA_EXHAUSTED: "Quota exhausted",
33
+ EscalationReason.DEPENDENCY_CANCELLED: "Dependency cancelled",
34
+ EscalationReason.ENGINE_ERROR: "Engine error",
35
+ }
36
+
37
+
38
+ ESCALATION_CAUSES: dict[EscalationReason, str] = {
39
+ EscalationReason.DECISION_REQUIRED: "The Leader needs an Operator decision to continue.",
40
+ EscalationReason.ITERATIONS_EXHAUSTED: "The final review round still requested changes.",
41
+ EscalationReason.INTEGRATION_CONFLICT: "Rebase conflicts could not be resolved automatically.",
42
+ EscalationReason.INTEGRATION_BLOCKED: "Integration is blocked: see the question.",
43
+ EscalationReason.UNCOMMITTED_WORK: (
44
+ "The task workspace has uncommitted changes; commit or discard them, then retry."
45
+ ),
46
+ EscalationReason.OFF_TASK_BRANCH: (
47
+ "The task workspace is not on its task branch; move its work onto the branch, then retry."
48
+ ),
49
+ EscalationReason.PR_CLOSED: "The pull request was closed without merging.",
50
+ EscalationReason.AGENT_AUTH: (
51
+ "The agent cannot access its provider; check credentials, billing, or account status."
52
+ ),
53
+ EscalationReason.AGENT_ERROR: "The agent kept failing after every automatic retry.",
54
+ EscalationReason.AGENT_STUCK: "The agent's screen stayed stuck after every automatic recovery.",
55
+ EscalationReason.AGENT_PROTOCOL: "The turn never produced a report, even with a Helper rebuild",
56
+ EscalationReason.LADDER_EXHAUSTED: "Every rung on the ladder was tried without success.",
57
+ EscalationReason.QUOTA_EXHAUSTED: "The agent's quota stayed exhausted past the wait budget.",
58
+ EscalationReason.DEPENDENCY_CANCELLED: "A dependency of this task was cancelled.",
59
+ EscalationReason.ENGINE_ERROR: "The engine hit an unexpected error while running this task.",
60
+ }
61
+
62
+ RESOLUTION_CONSEQUENCES: dict[ResolutionKind, str] = {
63
+ ResolutionKind.ANSWER: "Give the Leader guidance; the task continues from a consult turn.",
64
+ ResolutionKind.RETRY: "Re-run the step that raised this escalation.",
65
+ ResolutionKind.ACCEPT: "Integrate the work as it stands.",
66
+ ResolutionKind.CANCEL: (
67
+ "Cancel the task and keep its branch; preserve its worktree when uncommitted work remains "
68
+ "or it is off its task branch."
69
+ ),
70
+ }
71
+
72
+
73
+ def escalation_reason_label(reason: EscalationReason) -> str:
74
+ """Return the Operator-facing name of `reason`."""
75
+ return ESCALATION_REASON_LABELS[reason]
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class EscalationOption:
80
+ """One option `resolve` accepts; exactly one option per reason is `recommended`."""
81
+
82
+ label: ResolutionKind
83
+ recommended: bool = False
84
+
85
+
86
+ _ANSWER_OR_CANCEL = (
87
+ EscalationOption(ResolutionKind.ANSWER, recommended=True),
88
+ EscalationOption(ResolutionKind.CANCEL),
89
+ )
90
+ _RETRY_OR_CANCEL = (
91
+ EscalationOption(ResolutionKind.RETRY, recommended=True),
92
+ EscalationOption(ResolutionKind.CANCEL),
93
+ )
94
+
95
+ _OPTIONS_BY_REASON: dict[EscalationReason, tuple[EscalationOption, ...]] = {
96
+ EscalationReason.DECISION_REQUIRED: _ANSWER_OR_CANCEL,
97
+ EscalationReason.ITERATIONS_EXHAUSTED: (
98
+ EscalationOption(ResolutionKind.ANSWER, recommended=True),
99
+ EscalationOption(ResolutionKind.ACCEPT),
100
+ EscalationOption(ResolutionKind.CANCEL),
101
+ ),
102
+ EscalationReason.INTEGRATION_CONFLICT: _RETRY_OR_CANCEL,
103
+ EscalationReason.INTEGRATION_BLOCKED: _RETRY_OR_CANCEL,
104
+ EscalationReason.UNCOMMITTED_WORK: _RETRY_OR_CANCEL,
105
+ EscalationReason.OFF_TASK_BRANCH: _RETRY_OR_CANCEL,
106
+ EscalationReason.PR_CLOSED: _RETRY_OR_CANCEL,
107
+ EscalationReason.AGENT_AUTH: _RETRY_OR_CANCEL,
108
+ EscalationReason.AGENT_ERROR: _RETRY_OR_CANCEL,
109
+ EscalationReason.AGENT_STUCK: _RETRY_OR_CANCEL,
110
+ EscalationReason.AGENT_PROTOCOL: _RETRY_OR_CANCEL,
111
+ EscalationReason.LADDER_EXHAUSTED: _RETRY_OR_CANCEL,
112
+ EscalationReason.QUOTA_EXHAUSTED: _RETRY_OR_CANCEL,
113
+ EscalationReason.DEPENDENCY_CANCELLED: _RETRY_OR_CANCEL,
114
+ EscalationReason.ENGINE_ERROR: _RETRY_OR_CANCEL,
115
+ }
116
+
117
+
118
+ def options_for(reason: EscalationReason) -> tuple[EscalationOption, ...]:
119
+ """Return the resolution options for `reason`, with exactly one recommended default."""
120
+ return _OPTIONS_BY_REASON[reason]
121
+
122
+
123
+ def reset_for_resolution(counters: Counters, reason: EscalationReason) -> Counters:
124
+ """Reset the persisted counter represented by `reason` when it is resolved."""
125
+ if reason in (EscalationReason.DECISION_REQUIRED, EscalationReason.ITERATIONS_EXHAUSTED):
126
+ return counters.model_copy(update={"consults": 0})
127
+ if reason in (
128
+ EscalationReason.INTEGRATION_CONFLICT,
129
+ EscalationReason.INTEGRATION_BLOCKED,
130
+ EscalationReason.PR_CLOSED,
131
+ ):
132
+ return counters.model_copy(update={"conflict_rounds": 0})
133
+ return counters
134
+
135
+
136
+ def transition_for_resolution(
137
+ task: Task,
138
+ reason: EscalationReason,
139
+ *,
140
+ step: Step | None,
141
+ resolution: ResolutionKind,
142
+ rung: Rung | None = None,
143
+ role: Role | None = None,
144
+ ) -> Transition:
145
+ """Build the state transition for one validated escalation resolution."""
146
+ counters = reset_for_resolution(task.counters, reason)
147
+ set_overrides: dict[Role, Rung] = {}
148
+ target_step = step or task.step
149
+ status = Status.QUEUED
150
+ if resolution is ResolutionKind.ANSWER:
151
+ target_step = Step.CONSULT
152
+ elif resolution is ResolutionKind.ACCEPT:
153
+ target_step = Step.INTEGRATE
154
+ counters = reset_for_integration_attempt(counters)
155
+ elif resolution is ResolutionKind.CANCEL:
156
+ status = Status.CANCELLED
157
+ if rung is not None:
158
+ if role is None:
159
+ message = "a rung resolution requires its acting role"
160
+ raise ValueError(message)
161
+ set_overrides[role] = rung
162
+ counters = reset_for_rung_change(counters)
163
+ return Transition(
164
+ base=task,
165
+ step=target_step,
166
+ status=status,
167
+ iteration=task.iteration,
168
+ counters=counters,
169
+ rung_index=dict(task.rung_index),
170
+ set_overrides=set_overrides,
171
+ )
172
+
173
+
174
+ class EscalationStatus(StrEnum):
175
+ """An escalation's lifecycle."""
176
+
177
+ OPEN = auto()
178
+ RESOLVED = auto()
179
+
180
+
181
+ class Escalation(DomainModel):
182
+ """One raised escalation (mirrors the `escalations` table)."""
183
+
184
+ id: str
185
+ task_id: str
186
+ step: Step | None = None
187
+ reason: EscalationReason
188
+ headline: str
189
+ packet_path: str
190
+ options: tuple[EscalationOption, ...]
191
+ status: EscalationStatus
192
+ resolution: str | None = None
193
+ created_at: datetime
194
+ resolved_at: datetime | None = None