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
shikiru/cli/render.py ADDED
@@ -0,0 +1,1475 @@
1
+ """Rich renderables shared by operator CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.cells import cell_len
10
+ from rich.console import Console, Group
11
+ from rich.style import Style
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from shikiru.domain.escalation import escalation_reason_label
16
+ from shikiru.domain.events import EventKind
17
+ from shikiru.domain.reports import (
18
+ SEVERITY_BLOCKER,
19
+ ConsultAnswer,
20
+ FeedbackAnalysis,
21
+ ImplementationReport,
22
+ PlanReport,
23
+ Report,
24
+ TriageReport,
25
+ Verdict,
26
+ )
27
+ from shikiru.domain.runs import RunStatus
28
+ from shikiru.domain.scheduling import WaitReason
29
+ from shikiru.domain.steps import Step
30
+ from shikiru.domain.tasks import Hold, Status, Task
31
+ from shikiru.errors import (
32
+ CommandFailed,
33
+ CommandRejectedError,
34
+ CommandTimeoutError,
35
+ ConfigError,
36
+ DatabaseError,
37
+ DetachedProcessError,
38
+ HeadlessOutputError,
39
+ InvariantError,
40
+ NotInitialisedError,
41
+ ProcessReplacementError,
42
+ ProjectNotRepositoryRootError,
43
+ RepositoryHasNoCommitsError,
44
+ ShikiruError,
45
+ StaleTaskBranchError,
46
+ UsageError,
47
+ )
48
+ from shikiru.operator.commands import AckStatus
49
+ from shikiru.operator.doctor import (
50
+ PLUGIN_CHECK_PREFIX,
51
+ PLUGIN_CHECK_SEPARATOR,
52
+ PLUGINS_CHECK_NAME,
53
+ PROBE_CHECK_PREFIX,
54
+ )
55
+ from shikiru.operator.queries import TaskNotFoundError
56
+ from shikiru.ui.age import format_duration
57
+ from shikiru.ui.compact import compact_count
58
+ from shikiru.ui.console import supports_osc8
59
+ from shikiru.ui.gauge import Gauge
60
+ from shikiru.ui.glyphs import (
61
+ GLYPH_DONE,
62
+ GLYPH_ENGINE,
63
+ GLYPH_PENDING,
64
+ GLYPH_STOPPED,
65
+ )
66
+ from shikiru.ui.section import (
67
+ BOLD_PRIMARY,
68
+ BOLD_STEEL,
69
+ Section,
70
+ WrappedGroups,
71
+ labelled_rows,
72
+ number,
73
+ row_name,
74
+ )
75
+ from shikiru.ui.session import (
76
+ escalation_count,
77
+ session_engine_value,
78
+ session_facts,
79
+ session_identity,
80
+ )
81
+ from shikiru.ui.size import format_size
82
+ from shikiru.ui.tokens import (
83
+ ALERT_COLOR,
84
+ AMBER,
85
+ PRIMARY_TEXT_COLOR,
86
+ SAGE,
87
+ SECONDARY_TEXT_COLOR,
88
+ STEEL_BLUE,
89
+ STYLE_BOLD,
90
+ SUCCESS_COLOR,
91
+ )
92
+
93
+ if TYPE_CHECKING:
94
+ from rich.console import RenderableType
95
+
96
+ from shikiru.agents.contracts import QuotaWindow
97
+ from shikiru.domain.escalation import Escalation
98
+ from shikiru.operator.commands import Ack
99
+ from shikiru.operator.detectors import InitWizardResult
100
+ from shikiru.operator.doctor import DoctorCheck, DoctorReport
101
+ from shikiru.operator.lifecycle import RestartResult, StartResult
102
+ from shikiru.operator.queries import (
103
+ EscalationView,
104
+ HumanStatusView,
105
+ StatusView,
106
+ TaskDetail,
107
+ TaskSummary,
108
+ UsageView,
109
+ )
110
+ from shikiru.operator.sessions import SessionDiagnostic, SessionRow
111
+ from shikiru.paths import project_config_path
112
+
113
+ STATUS_ROW_STACKED_BELOW_WIDTH = 60
114
+ SESSION_TABLE_MIN_WIDTH = 65
115
+ SESSION_TABLE_PADDING = 1
116
+ SESSION_TABLE_CELL_PADDING_WIDTH = 2
117
+ SESSION_TABLE_FIRST_COUNT_COLUMN = 2
118
+ SESSION_COUNT_LABELS = ("Running", "Queued", "Escalated")
119
+ SESSION_PATH_INDENT = 2
120
+ SESSION_COLUMN_LABEL = "Session"
121
+ SESSION_ENGINE_LABEL = "Engine"
122
+ STATUS_COST_FORMAT = ".2f"
123
+ STOPPED_RESULT_FIELD = "stopped"
124
+ STOPPING_RESULT_FIELD = "stopping"
125
+ _ERROR_SUMMARIES: dict[type[ShikiruError], str] = {
126
+ InvariantError: "Shikiru encountered an internal invariant failure",
127
+ UsageError: "The command arguments are invalid",
128
+ CommandRejectedError: "The engine rejected the command",
129
+ StaleTaskBranchError: "The existing task branch does not match the task base",
130
+ ConfigError: "Configuration is missing or invalid",
131
+ HeadlessOutputError: "An agent returned invalid structured output",
132
+ DatabaseError: "The project state database operation failed",
133
+ CommandFailed: "A command exited unsuccessfully",
134
+ CommandTimeoutError: "A command timed out",
135
+ DetachedProcessError: "A background process could not be launched",
136
+ ProcessReplacementError: "The terminal handoff could not be started",
137
+ }
138
+ STATUS_USAGE_COLUMN_PADDING = 2
139
+ STATUS_PAUSED_LABEL = "Paused"
140
+ STATUS_OPERATOR_CONTROL_LABEL = "Operator control"
141
+ PLUGIN_AGGREGATE_PART_COUNT = 2
142
+ PLUGIN_CHILD_PART_COUNT = 3
143
+ DOCTOR_PROBE_PART_COUNT = 2
144
+ DOCTOR_CHECK_NAMES = {
145
+ "tmux": "Tmux",
146
+ "git": "Git",
147
+ "repository": "Repository",
148
+ "config": "Configuration",
149
+ "db": "Database",
150
+ "cow": "Copy-on-write",
151
+ "engine-output": "Engine output",
152
+ "plugins": "Agents",
153
+ "config.plugins": "Agent configuration",
154
+ }
155
+ DOCTOR_PLUGIN_CHECK_NAMES = {"model_choices": "Model choices"}
156
+ STATUS_USAGE_TABLE_MIN_WIDTH = 80
157
+ STATUS_ESCALATION_NO_STEP_LABEL = "no step"
158
+ STATUS_USAGE_HEADERS = ("Agent", "Runs", "Input", "Output", "Costs", "Quota used 5h/7d")
159
+ ESCALATION_EVENT_HEADLINE_SEPARATORS = (" · ", ": ")
160
+ QUOTA_PERIODS = ("5h", "7d")
161
+ QUOTA_PROVIDER_SLOTS = ("primary", "secondary")
162
+ STATUS_STEP_LABELS = {
163
+ Step.TRIAGE: "Triaging",
164
+ Step.PLAN: "Planning",
165
+ Step.IMPLEMENT: "Implementing",
166
+ Step.CONSULT: "Consulting",
167
+ Step.VERIFY: "Verifying",
168
+ Step.INTEGRATE: "Integrating",
169
+ }
170
+
171
+
172
+ def init_renderable(
173
+ config_path: Path,
174
+ *,
175
+ console: Console,
176
+ include_placeholder_warning: bool,
177
+ ) -> RenderableType:
178
+ """Render the config path and the applicable model-review warning."""
179
+ absolute_path = config_path.resolve()
180
+ config_text = _path_text(absolute_path, linked=supports_osc8(console))
181
+ if not include_placeholder_warning:
182
+ result = _outcome_line(GLYPH_DONE, "Initialized", SUCCESS_COLOR)
183
+ result.append(" ")
184
+ result.append_text(config_text)
185
+ result.no_wrap = True
186
+ return result
187
+ next_step = (
188
+ (
189
+ "Models",
190
+ "Generated model fields are placeholders; review role ladders before start.",
191
+ None,
192
+ ),
193
+ ("Doctor", "Verify the project environment before start.", "shikiru doctor"),
194
+ )
195
+ return _outcome_renderable(
196
+ "Project initialized",
197
+ GLYPH_DONE,
198
+ SUCCESS_COLOR,
199
+ details=(("Config", config_text),),
200
+ next_step=next_step,
201
+ )
202
+
203
+
204
+ def init_wizard_result_renderable(
205
+ result: InitWizardResult,
206
+ *,
207
+ console: Console,
208
+ ) -> RenderableType:
209
+ """Render the concise CLI acknowledgement for the wizard's actual Doctor result."""
210
+ config_text = _path_text(result.config_path.resolve(), linked=supports_osc8(console))
211
+ if result.doctor_error is not None:
212
+ summary = "Project initialized; Doctor could not complete"
213
+ doctor_detail = result.doctor_error
214
+ elif result.doctor_report is None:
215
+ summary = "Project initialized; Doctor did not return a report"
216
+ doctor_detail = "Doctor did not return a report."
217
+ elif result.doctor_report.ok:
218
+ return _outcome_renderable(
219
+ "Project initialized and verified",
220
+ GLYPH_DONE,
221
+ SUCCESS_COLOR,
222
+ details=(("Config", config_text),),
223
+ )
224
+ else:
225
+ summary = "Project initialized; Doctor found issues"
226
+ doctor_detail = None
227
+ details: tuple[tuple[str, str | Text], ...] = (("Config", config_text),)
228
+ if doctor_detail is not None:
229
+ details += (("Doctor", doctor_detail),)
230
+ project_path = shlex.quote(str(result.config_path.resolve().parent.parent))
231
+ return _outcome_renderable(
232
+ summary,
233
+ GLYPH_STOPPED,
234
+ ALERT_COLOR,
235
+ details=details,
236
+ repair=(
237
+ (
238
+ "Doctor",
239
+ "Review the checks and run Doctor again.",
240
+ f"shikiru --project {project_path} doctor",
241
+ ),
242
+ ),
243
+ )
244
+
245
+
246
+ def start_renderable(result: StartResult) -> Text:
247
+ """Render the engine outcome of `start`."""
248
+ label = "Engine started" if result.started else "Engine already running"
249
+ return _outcome_line_for_cli(label, GLYPH_DONE, SUCCESS_COLOR)
250
+
251
+
252
+ def restart_renderable(result: RestartResult) -> Text:
253
+ """Render the engine outcome of `restart`."""
254
+ label = "Engine restarted" if result.restarted else "Engine started"
255
+ return _outcome_line_for_cli(label, GLYPH_DONE, SUCCESS_COLOR)
256
+
257
+
258
+ def error_renderable(error: ShikiruError) -> RenderableType:
259
+ """Render a domain error with its cause and any known recovery action."""
260
+ if isinstance(error, ProjectNotRepositoryRootError):
261
+ return _repository_root_error_renderable(error)
262
+ if isinstance(error, TaskNotFoundError):
263
+ project = shlex.quote(str(error.project_root))
264
+ return _outcome_renderable(
265
+ f"Task {error.task_id} was not found",
266
+ GLYPH_STOPPED,
267
+ ALERT_COLOR,
268
+ details=(("Cause", str(error)),),
269
+ repair=(
270
+ (
271
+ "Project status",
272
+ "Show recent tasks and select an existing task ID.",
273
+ f"shikiru --project {project} status",
274
+ ),
275
+ ),
276
+ )
277
+ if isinstance(error, RepositoryHasNoCommitsError):
278
+ project = shlex.quote(str(error.project_root))
279
+ return _outcome_renderable(
280
+ "Git repository has no commits",
281
+ GLYPH_STOPPED,
282
+ ALERT_COLOR,
283
+ details=(
284
+ ("Project", str(error.project_root)),
285
+ ("Cause", "HEAD does not identify a commit."),
286
+ ),
287
+ repair=(
288
+ (
289
+ "First commit",
290
+ "Create an initial commit without staging project files.",
291
+ f"git -C {project} commit --allow-empty -m 'Initial commit'",
292
+ ),
293
+ (
294
+ "Initialize",
295
+ "Run initialization after the first commit exists.",
296
+ f"shikiru --project {project} init --yes",
297
+ ),
298
+ ),
299
+ )
300
+ if isinstance(error, NotInitialisedError):
301
+ project_root = error.project_root
302
+ missing_path = error.missing_path
303
+ project = shlex.quote(str(project_root)) if project_root is not None else None
304
+ missing_config = project_root is not None and missing_path == project_config_path(
305
+ project_root
306
+ )
307
+ summary = (
308
+ "Project configuration is missing" if missing_config else "Project state is missing"
309
+ )
310
+ details = tuple(
311
+ (label, str(value))
312
+ for label, value in (("Project", project_root), ("Missing", missing_path))
313
+ if value is not None
314
+ )
315
+ if project_root is None or missing_path is None:
316
+ details += (("Cause", str(error)),)
317
+ initialize = f"shikiru --project {project} init" if project is not None else "shikiru init"
318
+ return _outcome_renderable(
319
+ summary if project_root is not None else "Project is not initialized",
320
+ GLYPH_STOPPED,
321
+ ALERT_COLOR,
322
+ details=details,
323
+ repair=(
324
+ (
325
+ "Initialize",
326
+ "Create fresh Shikiru state for this repository.",
327
+ initialize,
328
+ ),
329
+ (
330
+ "Project root",
331
+ "Run this command for a different project root.",
332
+ "shikiru --project PATH COMMAND",
333
+ ),
334
+ ("Help", "Review available commands.", "shikiru --help"),
335
+ ),
336
+ )
337
+ return _outcome_renderable(
338
+ _error_summary(error),
339
+ GLYPH_STOPPED,
340
+ ALERT_COLOR,
341
+ details=(("Cause", str(error)),),
342
+ )
343
+
344
+
345
+ def _error_summary(error: ShikiruError) -> str:
346
+ return _ERROR_SUMMARIES.get(type(error), str(error)) or "Shikiru reported an unspecified error"
347
+
348
+
349
+ def missing_submit_request_renderable() -> RenderableType:
350
+ return _outcome_renderable(
351
+ "A task request is required",
352
+ GLYPH_STOPPED,
353
+ ALERT_COLOR,
354
+ details=(
355
+ ("Command", "shikiru submit"),
356
+ ("Cause", "The required REQUEST argument is missing."),
357
+ ),
358
+ repair=(
359
+ (
360
+ "Syntax",
361
+ "Provide a quoted request and review the unchanged command options.",
362
+ "shikiru submit --help",
363
+ ),
364
+ ),
365
+ )
366
+
367
+
368
+ def status_renderable(
369
+ view: HumanStatusView,
370
+ *,
371
+ width: int,
372
+ uptime_seconds: float | None,
373
+ quota_warn_pct: int,
374
+ quota_alert_pct: int,
375
+ ) -> RenderableType:
376
+ """Render project or task status as open, width-wrapping rows."""
377
+ status = view.status
378
+ if status.task is not None:
379
+ return _task_status_renderable(status.task, status.escalations, width=width)
380
+ return _project_status_renderable(
381
+ status,
382
+ view.task_rows,
383
+ width=width,
384
+ uptime_seconds=uptime_seconds,
385
+ quota_warn_pct=quota_warn_pct,
386
+ quota_alert_pct=quota_alert_pct,
387
+ )
388
+
389
+
390
+ def _project_status_renderable(
391
+ view: StatusView,
392
+ task_rows: tuple[TaskSummary, ...],
393
+ *,
394
+ width: int,
395
+ uptime_seconds: float | None,
396
+ quota_warn_pct: int,
397
+ quota_alert_pct: int,
398
+ ) -> RenderableType:
399
+ engine_style = f"{STYLE_BOLD} {SUCCESS_COLOR}" if view.engine_up else SECONDARY_TEXT_COLOR
400
+ engine = Text.assemble(
401
+ (GLYPH_ENGINE, engine_style),
402
+ (" up" if view.engine_up else " down", engine_style),
403
+ )
404
+ if view.engine_up and uptime_seconds is not None:
405
+ engine.append(f" ({format_duration(uptime_seconds)})", style=PRIMARY_TEXT_COLOR)
406
+ summary = [
407
+ Text(view.project, style=BOLD_PRIMARY),
408
+ engine,
409
+ _fact("slots", f"{view.slots.active}/{view.slots.maximum}", BOLD_PRIMARY),
410
+ _fact("queued", str(len(view.queued_tasks)), BOLD_PRIMARY),
411
+ Text.assemble(
412
+ ("escalations ", SECONDARY_TEXT_COLOR), escalation_count(len(view.escalations))
413
+ ),
414
+ _fact("space used", format_size(view.run_directories.size_bytes), PRIMARY_TEXT_COLOR),
415
+ ]
416
+ sections = _project_status_sections(
417
+ view,
418
+ task_rows,
419
+ width=width,
420
+ quota_warn_pct=quota_warn_pct,
421
+ quota_alert_pct=quota_alert_pct,
422
+ )
423
+ body: list[RenderableType] = [WrappedGroups(tuple(summary))]
424
+ for section in sections:
425
+ body.extend((Text(""), section))
426
+ return Group(*body)
427
+
428
+
429
+ def _project_status_sections(
430
+ view: StatusView,
431
+ task_rows: tuple[TaskSummary, ...],
432
+ *,
433
+ width: int,
434
+ quota_warn_pct: int,
435
+ quota_alert_pct: int,
436
+ ) -> list[RenderableType]:
437
+ blocks: list[RenderableType] = []
438
+ if view.engine_output is not None:
439
+ path_row = [
440
+ (
441
+ row_name("last engine output"),
442
+ (Text(view.engine_output, style=SECONDARY_TEXT_COLOR, overflow="fold"),),
443
+ )
444
+ ]
445
+ blocks.append(_status_rows(path_row, width=width))
446
+ if task_rows:
447
+ blocks.append(_task_section("Tasks", list(task_rows), width=width))
448
+ if view.faults:
449
+ blocks.append(_fault_section(view, width=width))
450
+ if view.escalations:
451
+ blocks.append(_escalation_section(view, width=width))
452
+ if view.usage or view.quotas:
453
+ blocks.append(
454
+ _usage_section(
455
+ view.usage,
456
+ view.quotas,
457
+ width=width,
458
+ quota_warn_pct=quota_warn_pct,
459
+ quota_alert_pct=quota_alert_pct,
460
+ )
461
+ )
462
+ return blocks
463
+
464
+
465
+ def _task_status_renderable(
466
+ detail: TaskDetail, escalations: list[EscalationView], *, width: int
467
+ ) -> RenderableType:
468
+ task = detail.task
469
+ state, state_color = _task_identity_state(task)
470
+ identity = Text.assemble(
471
+ (task.id, BOLD_PRIMARY),
472
+ (" · ", SECONDARY_TEXT_COLOR),
473
+ (task.title, PRIMARY_TEXT_COLOR),
474
+ (" · ", SECONDARY_TEXT_COLOR),
475
+ (state, f"{STYLE_BOLD} {state_color}"),
476
+ )
477
+ body: list[RenderableType] = [_status_text_rows([identity], width=width)]
478
+
479
+ blockers = _task_blocker_rows(detail, escalations)
480
+ if blockers:
481
+ body.extend((Text(""), _status_section("Attention", blockers, width=width)))
482
+ progress_rows = _task_progress_rows(task, state)
483
+ if progress_rows:
484
+ body.extend((Text(""), _status_section("Progress", progress_rows, width=width)))
485
+ report_rows = _latest_report_rows(detail)
486
+ if report_rows:
487
+ body.extend(
488
+ (
489
+ Text(""),
490
+ _status_text_section(
491
+ f"Latest report · 1 of {len(detail.reports)}", report_rows, width=width
492
+ ),
493
+ )
494
+ )
495
+ timeline_rows = _latest_event_rows(detail, escalations)
496
+ if timeline_rows:
497
+ body.extend(
498
+ (
499
+ Text(""),
500
+ _status_text_section(
501
+ f"Latest event · 1 of {len(detail.events)}", timeline_rows, width=width
502
+ ),
503
+ )
504
+ )
505
+ branch_rows = _branch_rows(detail)
506
+ if branch_rows:
507
+ body.extend((Text(""), _status_text_section("Branches", branch_rows, width=width)))
508
+ session_rows = _session_rows(detail)
509
+ if session_rows:
510
+ body.extend((Text(""), _status_section("Sessions", session_rows, width=width)))
511
+ if detail.evidence_errors:
512
+ body.extend(
513
+ (
514
+ Text(""),
515
+ _status_text_section(
516
+ "Evidence",
517
+ [Text(error, style=AMBER) for error in detail.evidence_errors],
518
+ width=width,
519
+ ),
520
+ )
521
+ )
522
+ return Group(*body)
523
+
524
+
525
+ def ack_renderable(ack: Ack, *, stopping: bool = False) -> RenderableType:
526
+ """Render an acknowledgement and known lifecycle outcomes."""
527
+ if stopping and ack.status is AckStatus.DONE:
528
+ outcome = _stop_outcome(ack)
529
+ if outcome is not None:
530
+ return _outcome_renderable(outcome, GLYPH_DONE, SUCCESS_COLOR)
531
+ return _outcome_renderable(
532
+ "Stop command acknowledged",
533
+ GLYPH_DONE,
534
+ SUCCESS_COLOR,
535
+ details=_ack_details(ack, None),
536
+ )
537
+ task_id = _ack_task_id(ack)
538
+ match ack.status:
539
+ case AckStatus.DONE:
540
+ next_step = (
541
+ (
542
+ (
543
+ "Progress",
544
+ "Follow the accepted task in project status.",
545
+ f"shikiru status {task_id}",
546
+ ),
547
+ )
548
+ if task_id is not None
549
+ else ()
550
+ )
551
+ return _outcome_renderable(
552
+ "Task created" if task_id is not None else "Command applied",
553
+ GLYPH_DONE,
554
+ SUCCESS_COLOR,
555
+ details=_ack_details(ack, task_id),
556
+ next_step=next_step,
557
+ )
558
+ case AckStatus.QUEUED:
559
+ return _outcome_renderable(
560
+ "Task submission remains pending"
561
+ if task_id is not None
562
+ else "Command remains pending",
563
+ GLYPH_PENDING,
564
+ AMBER,
565
+ details=_ack_details(ack, task_id),
566
+ next_step=(
567
+ ("Status", "Check whether the engine handled this request.", "shikiru status"),
568
+ ),
569
+ )
570
+ case AckStatus.REJECTED:
571
+ return _outcome_renderable(
572
+ "Task submission was rejected" if task_id is not None else "Command was rejected",
573
+ GLYPH_STOPPED,
574
+ ALERT_COLOR,
575
+ details=_ack_details(ack, task_id),
576
+ )
577
+
578
+
579
+ def doctor_renderable(report: DoctorReport, *, width: int) -> RenderableType:
580
+ """Render each diagnostic with its complete detail and an aggregate result."""
581
+ visible_checks = _doctor_display_checks(report.checks)
582
+ wrap_console = Console(width=max(1, width), force_terminal=False, color_system=None)
583
+ rows: list[RenderableType] = []
584
+ for check in visible_checks:
585
+ row = Text(overflow="fold")
586
+ if check.ok:
587
+ row.append(GLYPH_DONE, style=f"{STYLE_BOLD} {SUCCESS_COLOR}")
588
+ else:
589
+ row.append(GLYPH_STOPPED, style=f"{STYLE_BOLD} {ALERT_COLOR}")
590
+ row.append(" ")
591
+ row.append(_doctor_check_name(check.name), style=BOLD_PRIMARY)
592
+ row.append(" · ", style=SECONDARY_TEXT_COLOR)
593
+ row.append(check.detail, style=PRIMARY_TEXT_COLOR)
594
+ rows.extend(row.wrap(wrap_console, max(1, width), overflow="fold"))
595
+ if rows:
596
+ rows.append(Text(""))
597
+ if report.ok:
598
+ result = _outcome_line(GLYPH_DONE, "Doctor passed", SUCCESS_COLOR)
599
+ result.append(" · ", style=SECONDARY_TEXT_COLOR)
600
+ result.append(f"{len(visible_checks)} checks", style=BOLD_PRIMARY)
601
+ else:
602
+ failed_checks = sum(not check.ok for check in visible_checks)
603
+ result = _outcome_line(GLYPH_STOPPED, "Doctor failed", ALERT_COLOR)
604
+ result.append(" · ", style=SECONDARY_TEXT_COLOR)
605
+ result.append(
606
+ f"{failed_checks} of {len(visible_checks)} checks failed",
607
+ style=PRIMARY_TEXT_COLOR,
608
+ )
609
+ return Group(*rows, result)
610
+
611
+
612
+ def _doctor_display_checks(checks: list[DoctorCheck]) -> list[DoctorCheck]:
613
+ failed_plugins = {
614
+ parts[1]
615
+ for check in checks
616
+ if not check.ok
617
+ and (parts := check.name.split(PLUGIN_CHECK_SEPARATOR))[0] == PLUGIN_CHECK_PREFIX
618
+ and len(parts) >= PLUGIN_AGGREGATE_PART_COUNT
619
+ }
620
+ failed_plugins_with_details = {
621
+ parts[1]
622
+ for check in checks
623
+ if not check.ok
624
+ and (parts := check.name.split(PLUGIN_CHECK_SEPARATOR))[0] == PLUGIN_CHECK_PREFIX
625
+ and len(parts) >= PLUGIN_CHILD_PART_COUNT
626
+ }
627
+ plugins_with_details = {
628
+ parts[1]
629
+ for check in checks
630
+ if (parts := check.name.split(PLUGIN_CHECK_SEPARATOR))[0] == PLUGIN_CHECK_PREFIX
631
+ and len(parts) >= PLUGIN_CHILD_PART_COUNT
632
+ }
633
+ return [
634
+ check
635
+ for check in checks
636
+ if not (
637
+ (check.name == PLUGINS_CHECK_NAME and failed_plugins)
638
+ or (check.ok and check.name == PLUGINS_CHECK_NAME and plugins_with_details)
639
+ or (
640
+ not check.ok
641
+ and (parts := check.name.split(PLUGIN_CHECK_SEPARATOR))[0] == PLUGIN_CHECK_PREFIX
642
+ and len(parts) == PLUGIN_AGGREGATE_PART_COUNT
643
+ and parts[1] in failed_plugins_with_details
644
+ )
645
+ or (
646
+ check.ok
647
+ and (parts := check.name.split(PLUGIN_CHECK_SEPARATOR))[0] == PLUGIN_CHECK_PREFIX
648
+ and len(parts) == PLUGIN_AGGREGATE_PART_COUNT
649
+ and parts[1] in plugins_with_details
650
+ )
651
+ )
652
+ ]
653
+
654
+
655
+ def _doctor_check_name(name: str) -> str:
656
+ if name in DOCTOR_CHECK_NAMES:
657
+ return DOCTOR_CHECK_NAMES[name]
658
+ prefix, *parts = name.split(PLUGIN_CHECK_SEPARATOR)
659
+ if prefix == PLUGIN_CHECK_PREFIX:
660
+ if not parts:
661
+ return name
662
+ return _doctor_plugin_check_name(parts)
663
+ if prefix == PROBE_CHECK_PREFIX:
664
+ return _doctor_probe_check_name(name, parts)
665
+ return name
666
+
667
+
668
+ def _doctor_plugin_check_name(parts: list[str]) -> str:
669
+ if not parts:
670
+ return "Agent check"
671
+ if len(parts) == 1:
672
+ return f"Agent {parts[0]}"
673
+ check_name = PLUGIN_CHECK_SEPARATOR.join(parts[1:])
674
+ display_check = DOCTOR_PLUGIN_CHECK_NAMES.get(check_name, check_name)
675
+ return f"Agent {parts[0]} · {display_check}"
676
+
677
+
678
+ def _doctor_probe_check_name(name: str, parts: list[str]) -> str:
679
+ if len(parts) < DOCTOR_PROBE_PART_COUNT:
680
+ return name
681
+ return f"{parts[0]} probe · {' · '.join(parts[1:])}"
682
+
683
+
684
+ def session_rows_renderable(
685
+ rows: list[SessionRow],
686
+ *,
687
+ host: str | None,
688
+ console: Console,
689
+ unavailable: bool = False,
690
+ ) -> RenderableType:
691
+ """Render each session's identity, project and current progress as an open row."""
692
+ if not rows and unavailable:
693
+ return Group()
694
+ partial = bool(rows and unavailable)
695
+ lines: list[RenderableType] = [
696
+ _outcome_line_for_cli(
697
+ "Session discovery is partial" if partial else "Shikiru sessions discovered",
698
+ GLYPH_PENDING if partial else GLYPH_DONE,
699
+ AMBER if partial else SUCCESS_COLOR,
700
+ )
701
+ ]
702
+ if not rows:
703
+ lines[0] = _outcome_line_for_cli("No Shikiru sessions found", GLYPH_DONE, SUCCESS_COLOR)
704
+ if rows:
705
+ _append_outcome_heading(lines, "Details")
706
+ if host is not None:
707
+ lines.append(_outcome_detail_line("Host", host))
708
+ elif host is not None:
709
+ _append_outcome_section(lines, "Details", (("Host", host),))
710
+ if rows:
711
+ if console.width < SESSION_TABLE_MIN_WIDTH:
712
+ lines.extend(_stacked_session(row, console=console, host=host) for row in rows)
713
+ else:
714
+ lines.append(_session_table(rows, console=console, host=host))
715
+ return Group(*lines)
716
+
717
+
718
+ def session_diagnostics_renderable(
719
+ diagnostics: tuple[SessionDiagnostic, ...],
720
+ *,
721
+ remote_host: str | None = None,
722
+ ) -> RenderableType:
723
+ """Render unavailable local sessions with literal causes and repair commands."""
724
+ rows: list[RenderableType] = []
725
+ for diagnostic in diagnostics:
726
+ details: list[tuple[str, str]] = []
727
+ if remote_host is not None:
728
+ details.append(("Host", remote_host))
729
+ details.append(("Session", diagnostic.session))
730
+ if diagnostic.session_id is not None:
731
+ details.append(("Tmux ID", diagnostic.session_id))
732
+ details.extend(
733
+ (
734
+ ("Project", diagnostic.project_dir or "path not recorded"),
735
+ ("Cause", diagnostic.reason),
736
+ ("Note", diagnostic.note),
737
+ )
738
+ )
739
+ outcome = _outcome_renderable(
740
+ f"Project state is unavailable for {diagnostic.session}",
741
+ GLYPH_STOPPED,
742
+ ALERT_COLOR,
743
+ details=tuple(details),
744
+ repair=tuple(
745
+ (repair.label, repair.instruction, repair.command) for repair in diagnostic.repairs
746
+ ),
747
+ )
748
+ rows.extend((Text(""), outcome))
749
+ if remote_host is not None and diagnostics:
750
+ rows.append(
751
+ Text(
752
+ f"Run the repair commands above on SSH host {remote_host}.",
753
+ style=SECONDARY_TEXT_COLOR,
754
+ )
755
+ )
756
+ return Group(*rows)
757
+
758
+
759
+ def session_options_renderable(
760
+ rows: tuple[SessionRow, ...], *, host: str | None, console: Console
761
+ ) -> RenderableType:
762
+ """Render numbered, full-identity options for the terminal attach prompt."""
763
+ values: list[RenderableType] = [
764
+ _outcome_line_for_cli(
765
+ "Choose a session from the discovered list", GLYPH_DONE, SUCCESS_COLOR
766
+ )
767
+ ]
768
+ _append_outcome_heading(values, "Details")
769
+ if host is not None:
770
+ values.append(_outcome_detail_line("Host", host))
771
+ for index, row in enumerate(rows, start=1):
772
+ identity = Text.assemble((f"{index}. ", BOLD_STEEL))
773
+ identity.append_text(session_identity(row.session))
774
+ path = _path_text(Path(row.project_dir), linked=host is None and supports_osc8(console))
775
+ values.extend(
776
+ (
777
+ identity,
778
+ *_wrap_text(path, width=console.width, indent=SESSION_PATH_INDENT),
779
+ WrappedGroups(
780
+ session_facts(row.engine_state, row.running, row.queued, row.escalated)
781
+ ),
782
+ )
783
+ )
784
+ return Group(*values)
785
+
786
+
787
+ def _stacked_session(row: SessionRow, *, console: Console, host: str | None) -> RenderableType:
788
+ path = _path_text(Path(row.project_dir), linked=host is None and supports_osc8(console))
789
+ return Group(
790
+ *_wrap_text(session_identity(row.session), width=console.width),
791
+ *_wrap_text(path, width=console.width, indent=SESSION_PATH_INDENT),
792
+ WrappedGroups(session_facts(row.engine_state, row.running, row.queued, row.escalated)),
793
+ )
794
+
795
+
796
+ def _session_table(rows: list[SessionRow], *, console: Console, host: str | None) -> RenderableType:
797
+ widths = _session_table_widths(rows, console.width)
798
+ values: list[RenderableType] = [_session_table_line(widths, _session_table_headers())]
799
+ for index, row in enumerate(rows):
800
+ if index:
801
+ values.append(Text(""))
802
+ cells = _session_table_cells(row)
803
+ if cell_len(cells[0].plain) > widths[0]:
804
+ values.extend(_wrap_text(cells[0], width=console.width))
805
+ cells = (Text(""), *cells[1:])
806
+ values.append(_session_table_line(widths, cells))
807
+ path = _path_text(Path(row.project_dir), linked=host is None and supports_osc8(console))
808
+ values.extend(_wrap_text(path, width=console.width, indent=SESSION_PATH_INDENT))
809
+ return Group(*values)
810
+
811
+
812
+ def _session_table_widths(rows: list[SessionRow], width: int) -> tuple[int, ...]:
813
+ headers = _session_table_headers()
814
+ cells = tuple(_session_table_cells(row) for row in rows)
815
+ columns = tuple(
816
+ max(cell_len(header.plain), *(cell_len(row[index].plain) for row in cells))
817
+ for index, header in enumerate(headers[1:], start=1)
818
+ )
819
+ padding_width = len(headers) * SESSION_TABLE_CELL_PADDING_WIDTH * SESSION_TABLE_PADDING
820
+ session_width = max(1, width - sum(columns) - padding_width)
821
+ return (session_width, *columns)
822
+
823
+
824
+ def _session_table_headers() -> tuple[Text, ...]:
825
+ return (
826
+ Text(SESSION_COLUMN_LABEL, style=SECONDARY_TEXT_COLOR),
827
+ Text(SESSION_ENGINE_LABEL, style=SECONDARY_TEXT_COLOR),
828
+ *(Text(label, style=SECONDARY_TEXT_COLOR) for label in SESSION_COUNT_LABELS),
829
+ )
830
+
831
+
832
+ def _session_table_cells(row: SessionRow) -> tuple[Text, ...]:
833
+ return (
834
+ session_identity(row.session),
835
+ session_engine_value(row.engine_state),
836
+ number(str(row.running)),
837
+ number(str(row.queued)),
838
+ escalation_count(row.escalated),
839
+ )
840
+
841
+
842
+ def _session_table_line(widths: tuple[int, ...], cells: tuple[Text, ...]) -> Table:
843
+ table = Table.grid(padding=(0, SESSION_TABLE_PADDING), expand=True)
844
+ for index, width in enumerate(widths):
845
+ table.add_column(
846
+ width=width,
847
+ overflow="fold",
848
+ justify="right" if index >= SESSION_TABLE_FIRST_COUNT_COLUMN else "left",
849
+ )
850
+ table.add_row(*cells)
851
+ return table
852
+
853
+
854
+ def _path_text(path: Path, *, linked: bool) -> Text:
855
+ text = Text(str(path), style=SECONDARY_TEXT_COLOR, overflow="fold")
856
+ if linked:
857
+ text.stylize(Style(link=path.as_uri()))
858
+ return text
859
+
860
+
861
+ def _wrap_text(text: Text, *, width: int, indent: int = 0) -> list[Text]:
862
+ line_width = max(1, width - indent)
863
+ console = Console(width=line_width, force_terminal=False, color_system=None)
864
+ lines = text.wrap(console, line_width, overflow="fold")
865
+ if indent:
866
+ indented: list[Text] = []
867
+ for line in lines:
868
+ leading = Text(" " * indent, style=SECONDARY_TEXT_COLOR)
869
+ leading.append_text(line)
870
+ indented.append(leading)
871
+ return indented
872
+ return list(lines)
873
+
874
+
875
+ def _status_section(
876
+ title: str, rows: list[tuple[Text, tuple[Text, ...]]], *, width: int
877
+ ) -> Section:
878
+ return _section(title, _status_rows(rows, width=width))
879
+
880
+
881
+ def _status_text_section(title: str, rows: list[Text], *, width: int) -> Section:
882
+ return _section(title, _status_text_rows(rows, width=width))
883
+
884
+
885
+ def _section(title: str, body: RenderableType) -> Section:
886
+ return Section(title, body, ruled=False, title_style=BOLD_STEEL)
887
+
888
+
889
+ def _status_rows(rows: list[tuple[Text, tuple[Text, ...]]], *, width: int) -> RenderableType:
890
+ return labelled_rows(
891
+ rows, width, _task_label_width(rows), stack_below=STATUS_ROW_STACKED_BELOW_WIDTH
892
+ )
893
+
894
+
895
+ def _status_text_rows(rows: list[Text], *, width: int) -> RenderableType:
896
+ table = Table.grid(padding=0)
897
+ table.add_column(width=width, overflow="fold")
898
+ for row in rows:
899
+ table.add_row(row)
900
+ return table
901
+
902
+
903
+ def _fact(label: str, value: str, value_style: str = PRIMARY_TEXT_COLOR) -> Text:
904
+ return Text.assemble((f"{label} ", SECONDARY_TEXT_COLOR), (value, value_style))
905
+
906
+
907
+ def _fault_section(view: StatusView, *, width: int) -> Section:
908
+ rows: list[tuple[Text, tuple[Text, ...]]] = []
909
+ for fault in view.faults:
910
+ scope = fault.scope if fault.task_id is None else f"{fault.scope} · {fault.task_id}"
911
+ recoverable = "yes" if fault.recoverable else "no"
912
+ rows.append(
913
+ (
914
+ row_name(scope),
915
+ (
916
+ _fact("error", fault.error),
917
+ _fact("first", fault.first_seen.isoformat()),
918
+ _fact("last", fault.last_seen.isoformat()),
919
+ _fact("repeats", str(fault.repeat), BOLD_PRIMARY),
920
+ _fact("recoverable", recoverable, BOLD_PRIMARY),
921
+ ),
922
+ )
923
+ )
924
+ return _status_section("Faults", rows, width=width)
925
+
926
+
927
+ def _escalation_section(view: StatusView, *, width: int) -> Section:
928
+ rows: list[Text] = []
929
+ for item in view.escalations:
930
+ escalation = item.escalation
931
+ row = Text(overflow="fold")
932
+ row.append(escalation.id, style=f"{STYLE_BOLD} {AMBER}")
933
+ row.append(" · ", style=SECONDARY_TEXT_COLOR)
934
+ row.append(escalation.task_id, style=BOLD_STEEL)
935
+ row.append(" · ", style=SECONDARY_TEXT_COLOR)
936
+ row.append(_status_escalation_headline(escalation), style=PRIMARY_TEXT_COLOR)
937
+ rows.append(row)
938
+ return _section("Escalations", _status_text_rows(rows, width=width))
939
+
940
+
941
+ def _status_escalation_headline(escalation: Escalation) -> str:
942
+ step = escalation.step.value if escalation.step is not None else STATUS_ESCALATION_NO_STEP_LABEL
943
+ prefix = f"{escalation.task_id} · {step} · {escalation.reason.value}: "
944
+ return escalation.headline.removeprefix(prefix)
945
+
946
+
947
+ def _usage_section(
948
+ usages: list[UsageView],
949
+ quotas: dict[str, list[QuotaWindow]],
950
+ *,
951
+ width: int,
952
+ quota_warn_pct: int,
953
+ quota_alert_pct: int,
954
+ ) -> Section:
955
+ usage_by_agent = {usage.agent: usage for usage in usages}
956
+ agents = sorted(usage_by_agent.keys() | quotas.keys())
957
+ if width < STATUS_USAGE_TABLE_MIN_WIDTH:
958
+ rows = [
959
+ (
960
+ row_name(agent),
961
+ _usage_groups(
962
+ usage_by_agent.get(agent),
963
+ quotas.get(agent, []),
964
+ quota_warn_pct=quota_warn_pct,
965
+ quota_alert_pct=quota_alert_pct,
966
+ ),
967
+ )
968
+ for agent in agents
969
+ ]
970
+ return _status_section("Usage", rows, width=width)
971
+
972
+ table = Table.grid(expand=False, padding=(0, STATUS_USAGE_COLUMN_PADDING))
973
+ headers = STATUS_USAGE_HEADERS
974
+ for index, header in enumerate(headers):
975
+ table.add_column(
976
+ header,
977
+ justify="left" if index in (0, len(headers) - 1) else "right",
978
+ overflow="fold",
979
+ )
980
+ table.add_row(*(Text(header, style=SECONDARY_TEXT_COLOR) for header in headers))
981
+ for agent in agents:
982
+ usage = usage_by_agent.get(agent)
983
+ table.add_row(
984
+ Text(agent, style=BOLD_PRIMARY),
985
+ Text(str(usage.run_count if usage is not None else 0), style=BOLD_PRIMARY),
986
+ _token_total(usage, input_tokens=True),
987
+ _token_total(usage, input_tokens=False),
988
+ _cost_total(usage),
989
+ _quota_cell(
990
+ quotas.get(agent, []),
991
+ quota_warn_pct=quota_warn_pct,
992
+ quota_alert_pct=quota_alert_pct,
993
+ ),
994
+ )
995
+ return _section("Usage", table)
996
+
997
+
998
+ def _usage_groups(
999
+ usage: UsageView | None,
1000
+ windows: list[QuotaWindow],
1001
+ *,
1002
+ quota_warn_pct: int,
1003
+ quota_alert_pct: int,
1004
+ ) -> tuple[Text, ...]:
1005
+ return (
1006
+ _fact("Runs", str(usage.run_count if usage is not None else 0), BOLD_PRIMARY),
1007
+ _labeled_value("Input", _token_total(usage, input_tokens=True)),
1008
+ _labeled_value("Output", _token_total(usage, input_tokens=False)),
1009
+ _labeled_value("Costs", _cost_total(usage)),
1010
+ _labeled_value(
1011
+ "Quota used 5h/7d",
1012
+ _quota_cell(
1013
+ windows,
1014
+ quota_warn_pct=quota_warn_pct,
1015
+ quota_alert_pct=quota_alert_pct,
1016
+ ),
1017
+ ),
1018
+ )
1019
+
1020
+
1021
+ def _labeled_value(label: str, value: Text) -> Text:
1022
+ result = Text(label, style=SECONDARY_TEXT_COLOR)
1023
+ result.append(" ", style=SECONDARY_TEXT_COLOR)
1024
+ result.append_text(value)
1025
+ return result
1026
+
1027
+
1028
+ def _token_total(usage: UsageView | None, *, input_tokens: bool) -> Text:
1029
+ if usage is None or usage.run_count == 0:
1030
+ return Text("unknown", style=SECONDARY_TEXT_COLOR)
1031
+ known_runs = usage.usage_reported_runs + usage.legacy_token_runs
1032
+ if known_runs != usage.run_count:
1033
+ return Text("unknown", style=SECONDARY_TEXT_COLOR)
1034
+ tokens = usage.input_tokens if input_tokens else usage.output_tokens
1035
+ return Text(compact_count(tokens), style=BOLD_STEEL)
1036
+
1037
+
1038
+ def _cost_total(usage: UsageView | None) -> Text:
1039
+ if (
1040
+ usage is None
1041
+ or usage.run_count == 0
1042
+ or usage.billed_cost_runs != usage.run_count
1043
+ or usage.billed_cost_usd is None
1044
+ ):
1045
+ return Text("unknown", style=SECONDARY_TEXT_COLOR)
1046
+ return Text(f"${usage.billed_cost_usd:{STATUS_COST_FORMAT}}", style=SAGE)
1047
+
1048
+
1049
+ def _quota_cell(windows: list[QuotaWindow], *, quota_warn_pct: int, quota_alert_pct: int) -> Text:
1050
+ if not windows:
1051
+ return Text("unknown", style=SECONDARY_TEXT_COLOR)
1052
+ by_name = {window.name: window for window in windows}
1053
+ cell = Text()
1054
+ reported_periods = [period for period in QUOTA_PERIODS if period in by_name]
1055
+ if reported_periods:
1056
+ for index, period in enumerate(QUOTA_PERIODS):
1057
+ if index:
1058
+ cell.append(" / ", style=SECONDARY_TEXT_COLOR)
1059
+ window = by_name.get(period)
1060
+ cell.append_text(
1061
+ Text("unknown", style=SECONDARY_TEXT_COLOR)
1062
+ if window is None
1063
+ else Gauge("", window.used_pct, quota_warn_pct, quota_alert_pct).render()
1064
+ )
1065
+ other_windows = [window for window in windows if window.name not in QUOTA_PERIODS]
1066
+ else:
1067
+ order = {name: index for index, name in enumerate(QUOTA_PROVIDER_SLOTS)}
1068
+ other_windows = sorted(
1069
+ windows, key=lambda window: (order.get(window.name, len(order)), window.name)
1070
+ )
1071
+ for window in other_windows:
1072
+ if cell:
1073
+ cell.append(" · " if reported_periods else " / ", style=SECONDARY_TEXT_COLOR)
1074
+ cell.append_text(
1075
+ Gauge(
1076
+ "" if window.name in QUOTA_PROVIDER_SLOTS else window.name,
1077
+ window.used_pct,
1078
+ quota_warn_pct,
1079
+ quota_alert_pct,
1080
+ ).render()
1081
+ )
1082
+ return cell
1083
+
1084
+
1085
+ def _task_blocker_rows(
1086
+ detail: TaskDetail, escalations: list[EscalationView]
1087
+ ) -> list[tuple[Text, tuple[Text, ...]]]:
1088
+ task = detail.task
1089
+ rows: list[tuple[Text, tuple[Text, ...]]] = []
1090
+ rows.extend(
1091
+ (
1092
+ row_name("Escalation"),
1093
+ _escalation_attention_values(item.escalation),
1094
+ )
1095
+ for item in escalations
1096
+ if item.escalation.task_id == task.id
1097
+ )
1098
+ if task.status_reason is not None:
1099
+ rows.append((row_name("Outcome"), (Text(task.status_reason, style=SECONDARY_TEXT_COLOR),)))
1100
+ return rows
1101
+
1102
+
1103
+ def _escalation_attention_values(escalation: Escalation) -> tuple[Text, ...]:
1104
+ values = [
1105
+ Text(escalation.id, style=BOLD_PRIMARY),
1106
+ Text(escalation_reason_label(escalation.reason), style=AMBER),
1107
+ ]
1108
+ headline = _status_escalation_headline(escalation)
1109
+ values.append(Text(headline, style=PRIMARY_TEXT_COLOR, overflow="fold"))
1110
+ return tuple(values)
1111
+
1112
+
1113
+ def _task_progress_rows(task: Task, state: str) -> list[tuple[Text, tuple[Text, ...]]]:
1114
+ rows: list[tuple[Text, tuple[Text, ...]]] = []
1115
+ if task.step is not None:
1116
+ stage = STATUS_STEP_LABELS[Step(task.step)]
1117
+ if stage != state:
1118
+ rows.append((row_name("Recorded step"), (Text(stage, style=BOLD_STEEL),)))
1119
+ holds = sorted(
1120
+ hold
1121
+ for hold in task.holds
1122
+ if not (
1123
+ (hold == Hold.PAUSED.value and state == STATUS_PAUSED_LABEL)
1124
+ or (hold == Hold.INTERVENTION.value and state == STATUS_OPERATOR_CONTROL_LABEL)
1125
+ )
1126
+ )
1127
+ if holds:
1128
+ rows.append((row_name("Holds"), (Text(", ".join(holds), style=AMBER),)))
1129
+ return rows
1130
+
1131
+
1132
+ def _task_identity_state(task: Task) -> tuple[str, str]:
1133
+ if task.status is Status.ESCALATED:
1134
+ return "Escalated", AMBER
1135
+ if task.status is Status.CANCELLED:
1136
+ return "Cancelled", SECONDARY_TEXT_COLOR
1137
+ return _task_state(task)
1138
+
1139
+
1140
+ def _latest_report_rows(detail: TaskDetail) -> list[Text]:
1141
+ if not detail.reports:
1142
+ return []
1143
+ report = detail.reports[-1]
1144
+ rows = [
1145
+ Text.assemble(
1146
+ (report.turn, BOLD_PRIMARY),
1147
+ (" · ", SECONDARY_TEXT_COLOR),
1148
+ (report.kind.value, BOLD_STEEL),
1149
+ (" · ", SECONDARY_TEXT_COLOR),
1150
+ (_report_summary(report.data), PRIMARY_TEXT_COLOR),
1151
+ )
1152
+ ]
1153
+ if isinstance(report.data, Verdict):
1154
+ rows.extend(
1155
+ Text.assemble(
1156
+ (
1157
+ f" {issue.severity} · ",
1158
+ ALERT_COLOR if issue.severity == SEVERITY_BLOCKER else AMBER,
1159
+ ),
1160
+ (issue.description, PRIMARY_TEXT_COLOR),
1161
+ )
1162
+ for issue in report.data.issues
1163
+ )
1164
+ rows.extend(
1165
+ Text.assemble((" residual risk · ", AMBER), (risk, PRIMARY_TEXT_COLOR))
1166
+ for risk in report.data.residual_risks
1167
+ )
1168
+ return rows
1169
+
1170
+
1171
+ def _latest_event_rows(detail: TaskDetail, escalations: list[EscalationView]) -> list[Text]:
1172
+ if not detail.events:
1173
+ return []
1174
+ event = detail.events[-1]
1175
+ message = event.message
1176
+ if event.kind is EventKind.ESCALATION:
1177
+ duplicate_headline = next(
1178
+ (
1179
+ item.escalation.headline
1180
+ for item in escalations
1181
+ if item.escalation.task_id == event.task_id
1182
+ and item.escalation.headline
1183
+ and (
1184
+ event.message == item.escalation.headline
1185
+ or any(
1186
+ event.message.startswith(item.escalation.headline + separator)
1187
+ for separator in ESCALATION_EVENT_HEADLINE_SEPARATORS
1188
+ )
1189
+ )
1190
+ ),
1191
+ None,
1192
+ )
1193
+ if duplicate_headline is not None:
1194
+ message = event.message.removeprefix(duplicate_headline)
1195
+ for separator in ESCALATION_EVENT_HEADLINE_SEPARATORS:
1196
+ message = message.removeprefix(separator)
1197
+ row = Text.assemble(
1198
+ (event.ts.isoformat(), SECONDARY_TEXT_COLOR),
1199
+ (" · ", SECONDARY_TEXT_COLOR),
1200
+ (event.kind.value, BOLD_STEEL),
1201
+ )
1202
+ if message:
1203
+ row.append(" · ", style=SECONDARY_TEXT_COLOR)
1204
+ row.append(message, style=PRIMARY_TEXT_COLOR)
1205
+ return [row]
1206
+
1207
+
1208
+ def _branch_rows(detail: TaskDetail) -> list[Text]:
1209
+ branches = list(detail.branches)
1210
+ repo = detail.task.repo
1211
+ if repo is not None and repo.branch not in branches:
1212
+ branches.append(repo.branch)
1213
+ rows: list[Text] = []
1214
+ for branch in branches:
1215
+ row = Text(overflow="fold")
1216
+ row.append("branch ", style=SECONDARY_TEXT_COLOR)
1217
+ row.append(branch, style=PRIMARY_TEXT_COLOR)
1218
+ if repo is not None and branch == repo.branch:
1219
+ row.append(f" → {repo.base}", style=PRIMARY_TEXT_COLOR)
1220
+ rows.append(row)
1221
+ rows.append(
1222
+ Text.assemble(
1223
+ _fact("base ref", repo.base_ref),
1224
+ (" · ", SECONDARY_TEXT_COLOR),
1225
+ _fact("base sha", repo.base_sha),
1226
+ )
1227
+ )
1228
+ else:
1229
+ rows.append(row)
1230
+ return rows
1231
+
1232
+
1233
+ def _session_rows(detail: TaskDetail) -> list[tuple[Text, tuple[Text, ...]]]:
1234
+ rows: list[tuple[Text, tuple[Text, ...]]] = []
1235
+ for run in detail.sessions:
1236
+ label = run.role.value.title()
1237
+ status_style = {
1238
+ RunStatus.LIVE: SAGE,
1239
+ RunStatus.TAKEN_OVER: AMBER,
1240
+ RunStatus.CLOSED: SECONDARY_TEXT_COLOR,
1241
+ }[run.status]
1242
+ facts = [
1243
+ Text(f"{run.agent}/{run.model}", style=PRIMARY_TEXT_COLOR, overflow="fold"),
1244
+ Text(run.status.value, style=status_style),
1245
+ ]
1246
+ if run.session_id is not None:
1247
+ facts.append(_fact("session ID", run.session_id))
1248
+ if run.effort is not None:
1249
+ facts.append(_fact("effort", run.effort))
1250
+ rows.append((row_name(label), tuple(facts)))
1251
+ return rows
1252
+
1253
+
1254
+ def _task_label_width(rows: list[tuple[Text, tuple[Text, ...]]]) -> int:
1255
+ return max((cell_len(label.plain) for label, _ in rows), default=0)
1256
+
1257
+
1258
+ def _task_section(title: str, tasks: list[TaskSummary], *, width: int) -> Section:
1259
+ rows: list[Text] = []
1260
+ for task in tasks:
1261
+ row = Text(overflow="fold")
1262
+ row.append(task.id, style=BOLD_PRIMARY)
1263
+ row.append(" · ", style=SECONDARY_TEXT_COLOR)
1264
+ row.append(task.title, style=PRIMARY_TEXT_COLOR)
1265
+ row.append(" · ", style=SECONDARY_TEXT_COLOR)
1266
+ state, state_color = _task_state(task)
1267
+ row.append(state, style=state_color)
1268
+ rows.append(row)
1269
+ return Section(
1270
+ title,
1271
+ _status_text_rows(rows, width=width),
1272
+ ruled=False,
1273
+ title_style=BOLD_STEEL,
1274
+ )
1275
+
1276
+
1277
+ def _wait_label(wait_reason: str) -> str:
1278
+ if wait_reason == WaitReason.PR_OPEN.value:
1279
+ return "PR merge"
1280
+ return wait_reason.replace("_", " ")
1281
+
1282
+
1283
+ def _task_state(task: TaskSummary | Task) -> tuple[str, str]:
1284
+ if task.status is Status.DONE:
1285
+ return "Done", SAGE
1286
+ if Hold.PAUSED.value in task.holds or task.wait_reason == WaitReason.PAUSED.value:
1287
+ return STATUS_PAUSED_LABEL, AMBER
1288
+ if Hold.INTERVENTION.value in task.holds:
1289
+ return STATUS_OPERATOR_CONTROL_LABEL, AMBER
1290
+ if task.wait_reason is not None:
1291
+ return f"Waiting on {_wait_label(task.wait_reason)}", AMBER
1292
+ if task.status is Status.RUNNING and task.step is not None:
1293
+ step = task.step if isinstance(task.step, Step) else Step(task.step)
1294
+ return STATUS_STEP_LABELS[step], STEEL_BLUE
1295
+ return {
1296
+ Status.QUEUED: ("Queued", SECONDARY_TEXT_COLOR),
1297
+ Status.RUNNING: ("Running", STEEL_BLUE),
1298
+ Status.INTEGRATING: ("Integrating", STEEL_BLUE),
1299
+ Status.ESCALATED: ("Escalated", AMBER),
1300
+ Status.DONE: ("Done", SAGE),
1301
+ Status.CANCELLED: ("Cancelled", SECONDARY_TEXT_COLOR),
1302
+ }[task.status]
1303
+
1304
+
1305
+ def _report_summary(
1306
+ report: Report,
1307
+ ) -> str:
1308
+ match report:
1309
+ case TriageReport(title=title):
1310
+ return title
1311
+ case ImplementationReport(status=status, summary=summary):
1312
+ return f"{status}: {summary}" if summary else status
1313
+ case PlanReport(summary=summary):
1314
+ return summary
1315
+ case ConsultAnswer(decision=decision, answer=answer):
1316
+ return f"{decision}: {answer}" if answer else decision
1317
+ case Verdict(decision=decision, summary=summary):
1318
+ return f"{decision}: {summary}" if summary else decision
1319
+ case FeedbackAnalysis(summary=summary):
1320
+ return summary
1321
+
1322
+
1323
+ def _outcome_line(glyph: str, label: str, color: str) -> Text:
1324
+ text = Text()
1325
+ text.append(glyph, style=f"{STYLE_BOLD} {color}")
1326
+ text.append(" ")
1327
+ text.append(label, style=f"{STYLE_BOLD} {color}")
1328
+ return text
1329
+
1330
+
1331
+ def _outcome_line_for_cli(summary: str, glyph: str, color: str) -> Text:
1332
+ text = Text()
1333
+ text.append(glyph, style=f"{STYLE_BOLD} {color}")
1334
+ text.append(" ")
1335
+ text.append(summary, style=PRIMARY_TEXT_COLOR)
1336
+ return text
1337
+
1338
+
1339
+ def _outcome_renderable(
1340
+ summary: str,
1341
+ glyph: str,
1342
+ color: str,
1343
+ *,
1344
+ details: tuple[tuple[str, str | Text], ...] = (),
1345
+ repair: tuple[tuple[str, str, str | None], ...] = (),
1346
+ next_step: tuple[tuple[str, str, str | None], ...] = (),
1347
+ ) -> RenderableType:
1348
+ lines: list[RenderableType] = [_outcome_line_for_cli(summary, glyph, color)]
1349
+ _append_outcome_section(lines, "Details", details)
1350
+ _append_outcome_actions(lines, "Repair", repair)
1351
+ _append_outcome_actions(lines, "Next step", next_step)
1352
+ return Group(*lines)
1353
+
1354
+
1355
+ def _append_outcome_section(
1356
+ lines: list[RenderableType], title: str, rows: tuple[tuple[str, str | Text], ...]
1357
+ ) -> None:
1358
+ if not rows:
1359
+ return
1360
+ _append_outcome_heading(lines, title)
1361
+ for label, value in rows:
1362
+ lines.append(_outcome_detail_line(label, value))
1363
+
1364
+
1365
+ def _append_outcome_heading(lines: list[RenderableType], title: str) -> None:
1366
+ lines.append(Text(""))
1367
+ lines.append(Text(title, style=f"{STYLE_BOLD} {STEEL_BLUE}"))
1368
+
1369
+
1370
+ def _outcome_detail_line(label: str, value: str | Text) -> Text:
1371
+ row = Text(" ")
1372
+ row.append(label, style=SECONDARY_TEXT_COLOR)
1373
+ row.append(": ", style=SECONDARY_TEXT_COLOR)
1374
+ if isinstance(value, Text):
1375
+ row.append_text(value)
1376
+ else:
1377
+ row.append(value, style=PRIMARY_TEXT_COLOR)
1378
+ return row
1379
+
1380
+
1381
+ def _append_outcome_actions(
1382
+ lines: list[RenderableType],
1383
+ title: str,
1384
+ rows: tuple[tuple[str, str, str | None], ...],
1385
+ ) -> None:
1386
+ if not rows:
1387
+ return
1388
+ lines.append(Text(""))
1389
+ lines.append(Text(title, style=f"{STYLE_BOLD} {STEEL_BLUE}"))
1390
+ for label, instruction, command in rows:
1391
+ row = Text(" ")
1392
+ row.append(label, style=SECONDARY_TEXT_COLOR)
1393
+ row.append(": ", style=SECONDARY_TEXT_COLOR)
1394
+ row.append(instruction, style=PRIMARY_TEXT_COLOR)
1395
+ if command is not None:
1396
+ row.append(" · ", style=SECONDARY_TEXT_COLOR)
1397
+ row.append(command, style=BOLD_STEEL)
1398
+ lines.append(row)
1399
+
1400
+
1401
+ def _repository_root_error_renderable(
1402
+ error: ProjectNotRepositoryRootError,
1403
+ ) -> RenderableType:
1404
+ project_path = shlex.quote(str(error.project_root))
1405
+ if error.repository_root is None:
1406
+ return _outcome_renderable(
1407
+ "Project directory is not inside a Git repository",
1408
+ GLYPH_STOPPED,
1409
+ ALERT_COLOR,
1410
+ details=(
1411
+ ("Project", str(error.project_root)),
1412
+ ("Cause", "No Git worktree contains the selected directory."),
1413
+ ),
1414
+ repair=(
1415
+ (
1416
+ "Repository",
1417
+ "Initialize Git in this directory.",
1418
+ f"git -C {project_path} init",
1419
+ ),
1420
+ (
1421
+ "First commit",
1422
+ "Create an empty initial commit.",
1423
+ f"git -C {project_path} commit --allow-empty -m 'Initial commit'",
1424
+ ),
1425
+ ),
1426
+ )
1427
+ repository_path = shlex.quote(str(error.repository_root))
1428
+ return _outcome_renderable(
1429
+ "Selected project directory is below the Git repository root",
1430
+ GLYPH_STOPPED,
1431
+ ALERT_COLOR,
1432
+ details=(
1433
+ ("Project", str(error.project_root)),
1434
+ ("Repository root", str(error.repository_root)),
1435
+ ),
1436
+ repair=(
1437
+ (
1438
+ "Initialize",
1439
+ "Run initialization from the repository root.",
1440
+ f"shikiru --project {repository_path} init --yes",
1441
+ ),
1442
+ ),
1443
+ )
1444
+
1445
+
1446
+ def _ack_task_id(ack: Ack) -> str | None:
1447
+ if ack.submitted_task_id is not None:
1448
+ return ack.submitted_task_id
1449
+ result = ack.result
1450
+ task_id = result.get("task_id") if result is not None else None
1451
+ return task_id if isinstance(task_id, str) and task_id else None
1452
+
1453
+
1454
+ def _ack_details(ack: Ack, task_id: str | None) -> tuple[tuple[str, str], ...]:
1455
+ rows = [("Command", f"C-{ack.id}")]
1456
+ if task_id is not None:
1457
+ rows.append(("Task ID", task_id))
1458
+ message = ack.message
1459
+ if message is None and ack.result is not None:
1460
+ error = ack.result.get("error")
1461
+ message = str(error) if error is not None else None
1462
+ if message is not None:
1463
+ label = "Cause" if ack.status is AckStatus.REJECTED else "State"
1464
+ rows.append((label, message))
1465
+ return tuple(rows)
1466
+
1467
+
1468
+ def _stop_outcome(ack: Ack) -> str | None:
1469
+ if ack.status is not AckStatus.DONE or ack.result is None:
1470
+ return None
1471
+ if ack.result.get(STOPPED_RESULT_FIELD) is False:
1472
+ return "Engine already stopped"
1473
+ if ack.result.get(STOPPING_RESULT_FIELD) is True:
1474
+ return "Engine stopped"
1475
+ return None