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,216 @@
1
+ """Operator-facing summary and evidence of an open engine fault, from what was recorded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from typing import TYPE_CHECKING
10
+
11
+ from shikiru.dashboard.contract import EngineFaultView, FaultDetailRow
12
+ from shikiru.engine.pull_request_closing import CLOSE_FAULT_SCOPE
13
+ from shikiru.engine.push_service import PUSH_FAULT_SCOPE, REMOTE_CHECK_FAULT_SCOPE
14
+
15
+ if TYPE_CHECKING:
16
+ from shikiru.domain.faults import FaultStreak
17
+
18
+ _RECONCILE_SCOPE_PREFIX = "reconcile "
19
+ _NOT_RECORDED = "not recorded"
20
+ _KEY_SEPARATOR = "\x00"
21
+ _CAUSE_LIMIT = 160
22
+ _ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
23
+
24
+
25
+ class Recovery(StrEnum):
26
+ """How Shikiru itself retries a fault: the distinctions the Operator plans around."""
27
+
28
+ BACKOFF = "backoff"
29
+ BOUNDED = "bounded"
30
+ STARTUP_ONLY = "startup-only"
31
+ UNKNOWN = "unknown"
32
+
33
+
34
+ _STATUS = {
35
+ Recovery.BACKOFF: "Retrying automatically",
36
+ Recovery.BOUNDED: "Retrying a few times, then waiting for an engine restart",
37
+ Recovery.STARTUP_ONLY: "Retried only at the next engine start",
38
+ Recovery.UNKNOWN: "Retry timing unknown",
39
+ }
40
+ _UNEXPECTED_BACKOFF_STATUS = "Unexpected error · retrying automatically"
41
+ _UNRECOVERABLE_STEP = "Inspect the traceback and fix the unexpected error"
42
+ _UNKNOWN_CAUSE = "unknown: no error text was recorded"
43
+ _UNKNOWN_IMPACT = "not recorded for this kind of fault"
44
+ _UNKNOWN_STEP = "Read the cause in Details; no retry is known for this fault"
45
+ _RESTART_STEP = "Fix the cause, then restart the engine to retry sooner"
46
+ _LEGACY_COMMAND_FAILURE = re.compile(
47
+ r"(?:CommandFailed: )?command .*?\) exited (-?\d+):\s*(.*)\Z", re.DOTALL
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class RetryPolicy:
53
+ """The configured retry numbers the recovery descriptions quote."""
54
+
55
+ backoff_initial_seconds: float
56
+ backoff_max_seconds: float
57
+ close_attempts: int
58
+
59
+
60
+ def _target(scope: str, prefix: str) -> str | None:
61
+ head = f"{prefix} "
62
+ return scope.removeprefix(head) if scope.startswith(head) else None
63
+
64
+
65
+ def recovery_of(scope: str, *, recoverable: bool) -> Recovery:
66
+ """Return how `scope` is retried; a scope this view does not know retries at unknown times.
67
+
68
+ Whether the error is `recoverable` (environmental) does not change who schedules the retry,
69
+ except that the pull request close loop only retries environmental errors.
70
+ """
71
+ if _target(scope, PUSH_FAULT_SCOPE) is not None or _target(scope, REMOTE_CHECK_FAULT_SCOPE):
72
+ return Recovery.BACKOFF
73
+ if scope == CLOSE_FAULT_SCOPE:
74
+ return Recovery.BOUNDED if recoverable else Recovery.UNKNOWN
75
+ if scope.startswith(_RECONCILE_SCOPE_PREFIX):
76
+ return Recovery.STARTUP_ONLY
77
+ return Recovery.UNKNOWN
78
+
79
+
80
+ def _impact_and_step(scope: str) -> tuple[str, str] | None:
81
+ base = _target(scope, PUSH_FAULT_SCOPE)
82
+ if base is not None:
83
+ return (
84
+ f"commits on {base} stay local",
85
+ "Fix the cause git reports; Shikiru never forces a push",
86
+ )
87
+ if _target(scope, REMOTE_CHECK_FAULT_SCOPE) is not None:
88
+ return (
89
+ "no base is pushed until the remote can be read",
90
+ "Fix the git remote configuration, for example a malformed .git/config",
91
+ )
92
+ if scope == CLOSE_FAULT_SCOPE:
93
+ return "the pull request is not closed", _RESTART_STEP
94
+ if scope.startswith(_RECONCILE_SCOPE_PREFIX):
95
+ return "a startup recovery step did not complete", _RESTART_STEP
96
+ return None
97
+
98
+
99
+ def _policy_text(recovery: Recovery, policy: RetryPolicy) -> str:
100
+ backoff = f"{policy.backoff_initial_seconds:g}s doubling up to {policy.backoff_max_seconds:g}s"
101
+ return {
102
+ Recovery.BACKOFF: (
103
+ f"background push service logs failures and retries with backoff ({backoff}) "
104
+ "while the engine runs, even if the guard reaches its unexpected fault limit"
105
+ ),
106
+ Recovery.BOUNDED: (
107
+ f"up to {policy.close_attempts} attempts with backoff, {backoff}; "
108
+ "then again only at the next engine start"
109
+ ),
110
+ Recovery.STARTUP_ONLY: "attempted again only at the next engine start",
111
+ Recovery.UNKNOWN: "not recorded for this kind of fault",
112
+ }[recovery]
113
+
114
+
115
+ def _is_logged(scope: str) -> bool:
116
+ return (
117
+ _target(scope, PUSH_FAULT_SCOPE) is not None
118
+ or _target(scope, REMOTE_CHECK_FAULT_SCOPE) is not None
119
+ or scope.startswith(_RECONCILE_SCOPE_PREFIX)
120
+ )
121
+
122
+
123
+ def _shorten(text: str) -> str:
124
+ one_line = " ".join(text.split())
125
+ return one_line if len(one_line) <= _CAUSE_LIMIT else one_line[: _CAUSE_LIMIT - 1] + _ELLIPSIS
126
+
127
+
128
+ def _recorded_error(fault: FaultStreak) -> str:
129
+ if fault.detail is not None:
130
+ return fault.detail
131
+ return fault.message.removeprefix(f"{fault.scope} failed: ")
132
+
133
+
134
+ def _legacy_command_cause(error: str) -> str | None:
135
+ match = _LEGACY_COMMAND_FAILURE.fullmatch(error)
136
+ if match is None:
137
+ return None
138
+ exit_code, stderr = match.groups()
139
+ return f"exit {exit_code}: {stderr.strip()}" if stderr.strip() else f"exit {exit_code}"
140
+
141
+
142
+ def _cause(fault: FaultStreak) -> str:
143
+ if fault.exit_code is not None:
144
+ stderr = (fault.stderr or "").strip()
145
+ return _shorten(
146
+ f"exit {fault.exit_code}: {stderr}" if stderr else f"exit {fault.exit_code}"
147
+ )
148
+ error = _recorded_error(fault)
149
+ return _shorten(_legacy_command_cause(error) or error) or _UNKNOWN_CAUSE
150
+
151
+
152
+ def _command_rows(fault: FaultStreak) -> tuple[FaultDetailRow, ...]:
153
+ command = _NOT_RECORDED if fault.command is None else shlex.join(fault.command)
154
+ exit_code = _NOT_RECORDED if fault.exit_code is None else str(fault.exit_code)
155
+ return (
156
+ FaultDetailRow("Command", command),
157
+ FaultDetailRow("Exit code", exit_code),
158
+ FaultDetailRow("Stderr", _NOT_RECORDED if fault.stderr is None else fault.stderr.strip()),
159
+ )
160
+
161
+
162
+ def _details(
163
+ fault: FaultStreak,
164
+ recovery: Recovery,
165
+ policy: RetryPolicy,
166
+ *,
167
+ log_path: str,
168
+ state_path: str,
169
+ ) -> tuple[FaultDetailRow, ...]:
170
+ logged = (
171
+ (FaultDetailRow("Log", f"{log_path} (traceback of each failed attempt)"),)
172
+ if _is_logged(fault.scope)
173
+ else ()
174
+ )
175
+ return (
176
+ FaultDetailRow("Error", _recorded_error(fault).strip() or _UNKNOWN_CAUSE),
177
+ *_command_rows(fault),
178
+ FaultDetailRow("First seen", fault.first_seen.isoformat()),
179
+ FaultDetailRow("Latest", fault.last_seen.isoformat()),
180
+ FaultDetailRow("Failures", str(fault.repeat)),
181
+ FaultDetailRow(
182
+ "Classification",
183
+ "environmental failure" if fault.recoverable else "unexpected software error",
184
+ ),
185
+ FaultDetailRow("Retry policy", _policy_text(recovery, policy)),
186
+ FaultDetailRow("Next retry", _NOT_RECORDED),
187
+ FaultDetailRow("Traceback", _NOT_RECORDED if fault.traceback is None else fault.traceback),
188
+ FaultDetailRow("Event", f"#{fault.event_id} in {state_path}"),
189
+ *logged,
190
+ )
191
+
192
+
193
+ def engine_fault_view(
194
+ fault: FaultStreak, policy: RetryPolicy, *, log_path: str, state_path: str
195
+ ) -> EngineFaultView:
196
+ """Describe the open `fault` as a card summary plus the evidence recorded for it."""
197
+ recovery = recovery_of(fault.scope, recoverable=fault.recoverable)
198
+ known = _impact_and_step(fault.scope)
199
+ impact, step = known if known is not None else (_UNKNOWN_IMPACT, _UNKNOWN_STEP)
200
+ if not fault.recoverable:
201
+ step = _UNRECOVERABLE_STEP
202
+ return EngineFaultView(
203
+ key=f"{fault.task_id or ''}{_KEY_SEPARATOR}{fault.scope}",
204
+ task_id=fault.task_id,
205
+ scope=fault.scope,
206
+ status=(
207
+ _UNEXPECTED_BACKOFF_STATUS
208
+ if recovery is Recovery.BACKOFF and not fault.recoverable
209
+ else _STATUS[recovery]
210
+ ),
211
+ impact=impact,
212
+ cause=_cause(fault),
213
+ next_step=step,
214
+ needs_action=recovery is not Recovery.BACKOFF or not fault.recoverable,
215
+ details=_details(fault, recovery, policy, log_path=log_path, state_path=state_path),
216
+ )
@@ -0,0 +1,276 @@
1
+ """Bodies of the Results and Decisions folds: grey labels, white values, one row per fact."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.console import Group
10
+ from rich.text import Text
11
+
12
+ from shikiru.dashboard.briefing_view import (
13
+ BRANCH_ARROW,
14
+ BRANCH_LABEL,
15
+ COMMITS_LABEL,
16
+ MINUS,
17
+ PR_LABEL,
18
+ RISKS_LABEL,
19
+ SUMMARY_LABEL,
20
+ TESTS_LABEL,
21
+ branch_row,
22
+ decision_rows,
23
+ live_unavailable_rows,
24
+ pull_request_groups,
25
+ tests_groups,
26
+ )
27
+ from shikiru.domain.acceptance import request_without_criteria_section
28
+ from shikiru.domain.decisions import Decider
29
+ from shikiru.domain.results import ResultOutcome
30
+ from shikiru.ui.section import BOLD_STEEL, Row, grey, labelled_rows
31
+ from shikiru.ui.tokens import (
32
+ ALERT_COLOR,
33
+ AMBER,
34
+ STEEL_BLUE,
35
+ SUCCESS_COLOR,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
40
+
41
+ from shikiru.dashboard.contract import TaskDetailView
42
+ from shikiru.domain.briefing import ProducedNumbers
43
+ from shikiru.domain.decisions import DecisionEntry
44
+ from shikiru.domain.results import PlanChange, TaskResults
45
+
46
+ PRIORITY_WORDS = {0: "standard", 1: "high", -1: "low"}
47
+ CHANGES_LABEL = "Changes"
48
+ PLAN_LABEL = "Plan"
49
+ OPEN_LABEL = "Open"
50
+ NOTHING_RECORDED = "Nothing is recorded yet."
51
+ CRITERIA_LABEL = "Acceptance criteria"
52
+ NO_CRITERIA = "No validated acceptance criteria are recorded."
53
+ CRITERION_LEAD = "\N{BULLET} "
54
+ PATH_LABEL = "Path"
55
+ HOME_MARK = "~"
56
+ MODE_LABEL = "Mode"
57
+ SUBMITTED_LABEL = "Submitted"
58
+ REQUEUED_LABEL = "Requeued"
59
+ STATUS_REASON_LABEL = "Status reason"
60
+ PRIORITY_LABEL = "Priority"
61
+ MORE_COMMITS = "+{count} more"
62
+ ADDED_TASK = "added {id}"
63
+ CLOCK_FORMAT = "%H:%M"
64
+ TIME_GAP = " "
65
+ DECIDER_WIDTH = max(len(decider.value) for decider in Decider)
66
+
67
+
68
+ def _counted(count: int, noun: str) -> str:
69
+ return f"{count} {noun}{'' if count == 1 else 's'}"
70
+
71
+
72
+ def _stacked(label: str, lines: list[Text]) -> list[Row]:
73
+ return [(grey(label if index == 0 else ""), (line,)) for index, line in enumerate(lines)]
74
+
75
+
76
+ def _commit_rows(results: TaskResults) -> list[Row]:
77
+ lines = [
78
+ Text.assemble(Text(commit.sha, style=STEEL_BLUE), TIME_GAP, commit.subject)
79
+ for commit in results.commits
80
+ ]
81
+ if results.more_commits:
82
+ lines.append(grey(MORE_COMMITS.format(count=results.more_commits)))
83
+ return _stacked(COMMITS_LABEL, lines)
84
+
85
+
86
+ def _changes_groups(produced: ProducedNumbers | None) -> tuple[Text, ...]:
87
+ if produced is None:
88
+ return ()
89
+ return (
90
+ Text.assemble(
91
+ _counted(produced.files, "file"),
92
+ TIME_GAP,
93
+ (f"+{produced.insertions}", SUCCESS_COLOR),
94
+ " ",
95
+ (f"{MINUS}{produced.deletions}", ALERT_COLOR),
96
+ ),
97
+ )
98
+
99
+
100
+ def _plan_lines(change: PlanChange) -> list[Text]:
101
+ added = [
102
+ Text(ADDED_TASK.format(id=" ".join(filter(None, task))), style=SUCCESS_COLOR)
103
+ for task in change.new_tasks
104
+ ]
105
+ return [*added, *(Text(scope) for scope in change.scope_changes)]
106
+
107
+
108
+ def _summary_rows(results: TaskResults) -> list[Row]:
109
+ if results.plan_summary is None:
110
+ return []
111
+ return [(grey(SUMMARY_LABEL), (Text(results.plan_summary),))]
112
+
113
+
114
+ def _results_rows(results: TaskResults) -> list[Row]:
115
+ optional = (
116
+ (CHANGES_LABEL, _changes_groups(results.produced)),
117
+ (TESTS_LABEL, tests_groups(results.tests)),
118
+ (PR_LABEL, pull_request_groups(results.pull_request)),
119
+ )
120
+ plan = _stacked(
121
+ PLAN_LABEL, [line for change in results.plan_changes for line in _plan_lines(change)]
122
+ )
123
+ lead = [*plan, *_summary_rows(results)] if results.plan_summary is not None else []
124
+ return [
125
+ *live_unavailable_rows(results),
126
+ *lead,
127
+ *branch_row(results),
128
+ *_commit_rows(results),
129
+ *((grey(label), groups) for label, groups in optional if groups),
130
+ *([] if lead else plan),
131
+ *_stacked(RISKS_LABEL, [Text(risk, style=AMBER) for risk in results.residual_risks]),
132
+ ]
133
+
134
+
135
+ def _body(width: int, rows: list[Row], notes: list[Text]) -> RenderableType:
136
+ if not rows:
137
+ return Group(*notes, grey(NOTHING_RECORDED))
138
+ label_width = max(len(label.plain) for label, _ in rows)
139
+ return Group(*notes, labelled_rows(rows, width, label_width))
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class ResultsBody:
144
+ """The Results fold body: where the work went, what it contains and what is left open."""
145
+
146
+ results: TaskResults | None
147
+
148
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
149
+ """Yield the so-far note when work is in progress, then the labelled rows."""
150
+ del console
151
+ results = self.results
152
+ rows = _results_rows(results) if results is not None else []
153
+ so_far = results is not None and results.outcome is ResultOutcome.SO_FAR
154
+ yield _body(
155
+ options.max_width, rows, [grey(ResultOutcome.SO_FAR.value)] if so_far and rows else []
156
+ )
157
+
158
+
159
+ def _decision_row(entry: DecisionEntry) -> Row:
160
+ decider = Text(entry.decider.value.ljust(DECIDER_WIDTH), style=STEEL_BLUE)
161
+ label = Text.assemble(grey(entry.at.strftime(CLOCK_FORMAT)), TIME_GAP, decider)
162
+ return label, (
163
+ Text("\n").join([Text(entry.headline), *(grey(line) for line in entry.details)]),
164
+ )
165
+
166
+
167
+ def _decision_rows(view: TaskDetailView) -> list[Row]:
168
+ rows = decision_rows(view, OPEN_LABEL)
169
+ if view.decisions is not None:
170
+ rows.extend(_decision_row(entry) for entry in view.decisions.entries)
171
+ return rows
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class DecisionsBody:
176
+ """The Decisions fold body: the open question, then every decision oldest first."""
177
+
178
+ view: TaskDetailView
179
+
180
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
181
+ """Yield the labelled rows in order, or the nothing-recorded note."""
182
+ del console
183
+ yield _body(options.max_width, _decision_rows(self.view), [])
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class DescriptionBody:
188
+ """The Task description fold body: the request, then the acceptance criteria as a list.
189
+
190
+ When the criteria are listed, the request's own criteria section is left out of the text so
191
+ each criterion is shown once.
192
+ """
193
+
194
+ view: TaskDetailView
195
+
196
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
197
+ """Yield the request text and the criteria list."""
198
+ del console, options
199
+ criteria = self.view.acceptance_criteria
200
+ request = self.view.request
201
+ if criteria:
202
+ request = request_without_criteria_section(request)
203
+ yield Text(request)
204
+ yield Text(CRITERIA_LABEL, style=BOLD_STEEL)
205
+ if not criteria:
206
+ yield grey(NO_CRITERIA)
207
+ for item in criteria:
208
+ yield Text.assemble(grey(CRITERION_LEAD), item)
209
+
210
+
211
+ def home_shortened(path: str, home: Path) -> str:
212
+ """Return `path` as `~/...` when it lies under `home`, otherwise unchanged."""
213
+ location = Path(path)
214
+ return (
215
+ str(Path(HOME_MARK) / location.relative_to(home)) if location.is_relative_to(home) else path
216
+ )
217
+
218
+
219
+ def _workspace_rows(view: TaskDetailView, home: Path) -> list[Row]:
220
+ rows: list[Row] = []
221
+ if view.branch:
222
+ base = view.results.base if view.results is not None else None
223
+ target = view.branch if base is None else f"{view.branch} {BRANCH_ARROW} {base}"
224
+ rows.append((grey(BRANCH_LABEL), (Text(target),)))
225
+ rows.append((grey(PATH_LABEL), (Text(home_shortened(view.worktree_path, home)),)))
226
+ return rows
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class WorkspaceBody:
231
+ """The Workspace fold body: the task branch and where its worktree lives."""
232
+
233
+ view: TaskDetailView
234
+ home: Path
235
+
236
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
237
+ """Yield the labelled branch and path rows."""
238
+ del console
239
+ yield _body(options.max_width, _workspace_rows(self.view, self.home), [])
240
+
241
+
242
+ def priority_word(priority: int) -> str:
243
+ """Name a dispatch priority: `standard` at 0, `high` above it and `low` below it."""
244
+ return PRIORITY_WORDS[(priority > 0) - (priority < 0)]
245
+
246
+
247
+ def _present(value: str | None, style: str = "") -> Text | None:
248
+ return Text(value, style=style) if value else None
249
+
250
+
251
+ def _settings_rows(view: TaskDetailView) -> list[Row]:
252
+ entries = (
253
+ (MODE_LABEL, _present(view.mode)),
254
+ (SUBMITTED_LABEL, _present(view.submitted_age)),
255
+ (REQUEUED_LABEL, _present(view.requeued_age)),
256
+ (STATUS_REASON_LABEL, _present(view.status_reason, AMBER)),
257
+ (
258
+ PRIORITY_LABEL,
259
+ None
260
+ if view.priority is None
261
+ else Text(f"{priority_word(view.priority)} ({view.priority})"),
262
+ ),
263
+ )
264
+ return [(grey(label), (value,)) for label, value in entries if value is not None]
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class SettingsBody:
269
+ """The Settings fold body: mode, ages, status reason and dispatch priority."""
270
+
271
+ view: TaskDetailView
272
+
273
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
274
+ """Yield the labelled settings rows."""
275
+ del console
276
+ yield _body(options.max_width, _settings_rows(self.view), [])
@@ -0,0 +1,166 @@
1
+ """The one key table behind the Help panel: groups of key and action rows per context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+ from shikiru.dashboard.detail import TASK_DETAIL_VIEWS_KEY, TASK_WINDOW_KEY
9
+ from shikiru.dashboard.resolution_view import EXTRA_DETAILS_KEY
10
+ from shikiru.dashboard.timeline_filter import CLEAR_KEY, CYCLE_ROLE_KEY, CYCLE_TURN_KEY
11
+ from shikiru.dashboard.timeline_section import FILTER_KEY
12
+
13
+ _ARROWS = "Up/Down"
14
+ _ESC = "Esc"
15
+ _ENTER = "Enter"
16
+ _PALETTE = "Ctrl+P"
17
+ _DETACH_KEY = "d"
18
+ _PALETTE_POPUP_KEY = "Space"
19
+
20
+
21
+ class HelpTab(StrEnum):
22
+ """A Help context; the value is the tab label."""
23
+
24
+ HOME = "Home"
25
+ TASK = "Task details"
26
+ ESCALATIONS = "Escalations"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class HelpRow:
31
+ """One key and what it does."""
32
+
33
+ keys: str
34
+ action: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class HelpGroup:
39
+ """A titled run of rows."""
40
+
41
+ title: str
42
+ rows: tuple[HelpRow, ...]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class HelpAvailability:
47
+ """Which optional parts of Home exist, so their keys are listed only when they work."""
48
+
49
+ queue: bool
50
+ escalations: bool
51
+ detach_prefix: str | None
52
+
53
+
54
+ def help_groups(tab: HelpTab, availability: HelpAvailability) -> tuple[HelpGroup, ...]:
55
+ """Return the groups of `tab`, with the Home keys that exist under `availability`."""
56
+ if tab is HelpTab.HOME:
57
+ return _home_groups(availability)
58
+ if tab is HelpTab.TASK:
59
+ return _task_groups()
60
+ return _escalation_groups()
61
+
62
+
63
+ def _home_groups(availability: HelpAvailability) -> tuple[HelpGroup, ...]:
64
+ tasks = [HelpRow("Click", "Select a task and show its details")]
65
+ if availability.queue:
66
+ tasks[:0] = [
67
+ HelpRow(_ARROWS, "Select a task, wherever focus is"),
68
+ HelpRow("f", "Cycle the task status filter"),
69
+ HelpRow("/", "Search by task ID or title; Enter keeps it, Esc clears it"),
70
+ HelpRow("m", "Load more tasks, while the list shows the hint"),
71
+ ]
72
+ tasks.append(HelpRow(_ESC, "Close a popup, clear the search, then clear the selection"))
73
+ groups = [HelpGroup("Tasks", tuple(tasks))]
74
+ if availability.escalations:
75
+ groups.append(
76
+ HelpGroup(
77
+ "Escalations",
78
+ (HelpRow(_ENTER, "Open the decision"),),
79
+ )
80
+ )
81
+ everywhere = [
82
+ HelpRow(f"{_PALETTE} / F12", "Open the actions for the selected task"),
83
+ HelpRow("Tab", "Move to the footer, command status and resources; Enter uses them"),
84
+ HelpRow("?", "Show this help"),
85
+ ]
86
+ if availability.detach_prefix is not None:
87
+ prefix = availability.detach_prefix
88
+ everywhere.append(
89
+ HelpRow(
90
+ f"{prefix} {_PALETTE_POPUP_KEY}",
91
+ "Open the actions in any Shikiru pane (a popup in task and role panes)",
92
+ )
93
+ )
94
+ everywhere.append(HelpRow(f"{prefix} {_DETACH_KEY}", "Detach from tmux"))
95
+ groups.append(HelpGroup("Everywhere", tuple(everywhere)))
96
+ return tuple(groups)
97
+
98
+
99
+ def _task_groups() -> tuple[HelpGroup, ...]:
100
+ return (
101
+ HelpGroup(
102
+ "Task",
103
+ (
104
+ HelpRow("Tab", "Move between the task's actions and its sections"),
105
+ HelpRow(_ENTER, "Run the focused action"),
106
+ HelpRow(TASK_DETAIL_VIEWS_KEY, "Focus task details sections"),
107
+ HelpRow(_PALETTE, "Open the actions for this task"),
108
+ HelpRow(_ESC, "Step back, then return to Home"),
109
+ ),
110
+ ),
111
+ HelpGroup(
112
+ "Sections",
113
+ (
114
+ HelpRow(_ARROWS, "Move between section headers"),
115
+ HelpRow(_ENTER, "Open the section, or enter its list"),
116
+ HelpRow("Space / click", "Open or close a section"),
117
+ ),
118
+ ),
119
+ HelpGroup(
120
+ "Lists",
121
+ (
122
+ HelpRow(_ARROWS, "Select a row"),
123
+ HelpRow("PgUp/PgDn", "Page through the rows"),
124
+ HelpRow("End", "Jump to the latest and follow"),
125
+ HelpRow(_ENTER, "Open the selected row"),
126
+ HelpRow(_ESC, "Back to the section header"),
127
+ ),
128
+ ),
129
+ HelpGroup(
130
+ "Agent activity filter",
131
+ (
132
+ HelpRow(FILTER_KEY, "Open the filter"),
133
+ HelpRow(CYCLE_ROLE_KEY, "Cycle the role"),
134
+ HelpRow(CYCLE_TURN_KEY, "Cycle the turn"),
135
+ HelpRow(CLEAR_KEY, "Clear the filter"),
136
+ HelpRow(_ESC, "Close the filter"),
137
+ ),
138
+ ),
139
+ HelpGroup(
140
+ "Workspace section",
141
+ (HelpRow(TASK_WINDOW_KEY, "Open the task's tmux window while its section is focused"),),
142
+ ),
143
+ )
144
+
145
+
146
+ def _escalation_groups() -> tuple[HelpGroup, ...]:
147
+ return (
148
+ HelpGroup(
149
+ "Choose",
150
+ (
151
+ HelpRow(_ARROWS, "Choose an answer"),
152
+ HelpRow(_ENTER, "Send it, or edit or expand the chosen row"),
153
+ HelpRow("t", "Show or hide what was tried"),
154
+ HelpRow(EXTRA_DETAILS_KEY, "Open extra details (when Details is open)"),
155
+ HelpRow(_ESC, "Close without sending"),
156
+ ),
157
+ ),
158
+ HelpGroup(
159
+ "Write an answer",
160
+ (
161
+ HelpRow("Ctrl+S", "Send the typed answer"),
162
+ HelpRow(_ESC, "Back to the choices"),
163
+ ),
164
+ ),
165
+ HelpGroup("Cancel the task", (HelpRow(_ENTER, "Press Enter again to confirm"),)),
166
+ )