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,297 @@
1
+ """`TaskTitles`: pane and window titles, and window state decorations.
2
+
3
+ Refreshed at every task state boundary (dispatch, step, iteration, status, hold, rung and usage
4
+ change). The window's `@shk_state_color`/`@shk_state_glyph` mirror the task's own state — its
5
+ acting role and step, or a hold/escalation override — distinct from `engine.decorations`, which
6
+ mirrors a *session's* live activity on its own pane.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import TYPE_CHECKING
13
+
14
+ from shikiru.agents.contracts import COST_BASIS_API_EQUIVALENT, COST_BASIS_BILLED, Rung, Usage
15
+ from shikiru.domain.ladder import pick_rung
16
+ from shikiru.domain.levels import usage_level
17
+ from shikiru.domain.runs import NO_USAGE, RunStatus, context_title, cost_title
18
+ from shikiru.domain.steps import ACTING_ROLE
19
+ from shikiru.domain.tasks import Hold, Role, Status, Step
20
+ from shikiru.paths import run_dir
21
+ from shikiru.ui.glyphs import (
22
+ GLYPH_CONSULT,
23
+ GLYPH_ENGINE,
24
+ GLYPH_ESCALATED,
25
+ GLYPH_OPERATOR,
26
+ GLYPH_PAUSED,
27
+ GLYPH_QUOTA,
28
+ QUOTA_MARKER,
29
+ ROLE_GLYPHS,
30
+ STAGE_IMPLEMENT,
31
+ STAGE_MERGE,
32
+ STAGE_PLAN,
33
+ STAGE_TRIAGE,
34
+ STAGE_VERIFY,
35
+ )
36
+ from shikiru.ui.tokens import (
37
+ ALERT_COLOR,
38
+ AMBER,
39
+ ENGINE_COLOR,
40
+ HOLD_COLOR,
41
+ OPERATOR_COLOR,
42
+ PRIMARY_TEXT_COLOR,
43
+ ROLE_COLORS,
44
+ SAGE,
45
+ SECONDARY_TEXT_COLOR,
46
+ USAGE_LEVEL_COLORS,
47
+ )
48
+ from shikiru.view_log import (
49
+ clear_quota_status,
50
+ quota_status_path,
51
+ write_quota_status,
52
+ )
53
+
54
+ if TYPE_CHECKING:
55
+ from pathlib import Path
56
+
57
+ from shikiru.config.settings import Settings
58
+ from shikiru.domain.runs import Run
59
+ from shikiru.domain.tasks import Task
60
+ from shikiru.engine.quota_waits import QuotaWaits, TaskQuotaWait
61
+ from shikiru.store.runs import RunStore
62
+ from shikiru.store.tasks import TaskStore
63
+ from shikiru.tmux.decorator import PaneDecorator
64
+
65
+ _STEP_GLYPHS: dict[Step, str] = {
66
+ Step.TRIAGE: STAGE_TRIAGE,
67
+ Step.PLAN: STAGE_PLAN,
68
+ Step.IMPLEMENT: STAGE_IMPLEMENT,
69
+ Step.CONSULT: GLYPH_CONSULT,
70
+ Step.VERIFY: STAGE_VERIFY,
71
+ Step.INTEGRATE: STAGE_MERGE,
72
+ }
73
+
74
+ _TITLED_ROLES = (Role.LEADER, Role.IMPLEMENTER)
75
+
76
+
77
+ def _quota_shown(task: Task) -> bool:
78
+ return (
79
+ Hold.PAUSED not in task.holds
80
+ and Hold.INTERVENTION not in task.holds
81
+ and task.status is not Status.ESCALATED
82
+ )
83
+
84
+
85
+ def _window_state(task: Task, quota: TaskQuotaWait | None) -> tuple[str, str]:
86
+ if Hold.PAUSED in task.holds:
87
+ return HOLD_COLOR, GLYPH_PAUSED
88
+ if Hold.INTERVENTION in task.holds:
89
+ return OPERATOR_COLOR, GLYPH_OPERATOR
90
+ if task.status is Status.ESCALATED:
91
+ return ALERT_COLOR, GLYPH_ESCALATED
92
+ if quota is not None:
93
+ return AMBER, QUOTA_MARKER
94
+ if task.step is None:
95
+ return ENGINE_COLOR, GLYPH_ENGINE
96
+ return ROLE_COLORS[ACTING_ROLE[task.step]], _STEP_GLYPHS[task.step]
97
+
98
+
99
+ def _pane_state(
100
+ task: Task, role: Role, *, taken_over: bool, quota_blocked: bool
101
+ ) -> tuple[str, str]:
102
+ if taken_over:
103
+ return OPERATOR_COLOR, GLYPH_OPERATOR
104
+ if Hold.PAUSED in task.holds:
105
+ return HOLD_COLOR, GLYPH_PAUSED
106
+ if quota_blocked:
107
+ return AMBER, GLYPH_QUOTA
108
+ return ROLE_COLORS[role], ROLE_GLYPHS[role]
109
+
110
+
111
+ def _flag_parts(task: Task, *, taken_over: bool) -> list[tuple[str, str, str]]:
112
+ flags: list[tuple[str, str, str]] = []
113
+ if Hold.PAUSED in task.holds:
114
+ flags.append((GLYPH_PAUSED, "paused", HOLD_COLOR))
115
+ if taken_over or Hold.INTERVENTION in task.holds:
116
+ flags.append((GLYPH_OPERATOR, "operator", OPERATOR_COLOR))
117
+ if task.status is Status.ESCALATED:
118
+ flags.append((GLYPH_ESCALATED, "escalated", ALERT_COLOR))
119
+ return flags
120
+
121
+
122
+ def _flags(task: Task, *, taken_over: bool = False, include_glyphs: bool = True) -> str:
123
+ flags = _flag_parts(task, taken_over=taken_over)
124
+ if include_glyphs:
125
+ return " ".join(f"{glyph} {word}" for glyph, word, _ in flags)
126
+ return " ".join(word for _, word, _ in flags)
127
+
128
+
129
+ def _flag_segments(task: Task, *, taken_over: bool) -> tuple[tuple[str, str], ...]:
130
+ parts = _flag_parts(task, taken_over=taken_over)
131
+ segments: list[tuple[str, str]] = []
132
+ for index, (_, word, color) in enumerate(parts):
133
+ if index:
134
+ segments.append((" ", PRIMARY_TEXT_COLOR))
135
+ segments.append((word, color))
136
+ return tuple(segments)
137
+
138
+
139
+ def _rung_label(rung: Rung) -> str:
140
+ effort = f"/{rung.effort}" if rung.effort is not None else ""
141
+ return f"{rung.agent}:{rung.model}{effort}"
142
+
143
+
144
+ class TaskTitles:
145
+ """Sets `task`'s window/pane titles and window state from `dashboard.titles`.
146
+
147
+ A role's pane taken over by the Operator is titled and coloured as the Operator's;
148
+ otherwise it shows the pause hold or the role's own colour and glyph.
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ decorator: PaneDecorator,
154
+ runs: RunStore,
155
+ tasks: TaskStore,
156
+ settings: Settings,
157
+ *,
158
+ waits: QuotaWaits,
159
+ project_root: Path,
160
+ ) -> None:
161
+ """Decorate via `decorator` using current task rows, live run usage and quota `waits`.
162
+
163
+ Each role pane's quota status is published under `project_root`'s run directories for
164
+ the pane's view process.
165
+ """
166
+ self._decorator = decorator
167
+ self._runs = runs
168
+ self._tasks = tasks
169
+ self._settings = settings
170
+ self._waits = waits
171
+ self._project_root = project_root
172
+ self._refresh_lock = asyncio.Lock()
173
+ self._published: dict[str, TaskQuotaWait | None] = {}
174
+
175
+ async def sync_quota(self, task: Task) -> None:
176
+ """Refresh `task`'s decorations when its quota wait differs from the one last published."""
177
+ current = self._tasks.require(task.id)
178
+ if (
179
+ current.id not in self._published
180
+ or self._waits.resolve(current) != self._published[current.id]
181
+ ):
182
+ await self.refresh(current)
183
+
184
+ async def refresh(self, task: Task) -> None:
185
+ """Set titles from the latest task row, serializing title publications."""
186
+ async with self._refresh_lock:
187
+ current = self._tasks.require(task.id)
188
+ quota = self._waits.resolve(current)
189
+ self._published[current.id] = quota
190
+ if current.window_id is not None:
191
+ await self._refresh_window(current, current.window_id, quota)
192
+ for role in _TITLED_ROLES:
193
+ pane_id = current.panes.get(role)
194
+ if pane_id is not None:
195
+ await self._refresh_pane(current, role, pane_id, quota)
196
+
197
+ async def _refresh_window(
198
+ self, task: Task, window_target: str, quota: TaskQuotaWait | None
199
+ ) -> None:
200
+ color, glyph = _window_state(task, quota)
201
+ fields = {"task_id": task.id, "title": task.title}
202
+ await self._decorator.refresh_window(
203
+ window_target,
204
+ self._settings.dashboard.titles.window,
205
+ fields,
206
+ color=color,
207
+ glyph=glyph,
208
+ )
209
+
210
+ async def _refresh_pane(
211
+ self, task: Task, role: Role, pane_id: str, quota: TaskQuotaWait | None
212
+ ) -> None:
213
+ configured_rung = pick_rung(task, role, self._settings)
214
+ run = self._active_run(task.id, role)
215
+ taken_over = run is not None and run.status is RunStatus.TAKEN_OVER
216
+ effective_rung = (
217
+ Rung(agent=run.agent, model=run.model, effort=run.effort)
218
+ if run is not None
219
+ else configured_rung
220
+ )
221
+ pending_rung = (
222
+ f" · next {_rung_label(configured_rung)}" if effective_rung != configured_rung else ""
223
+ )
224
+ usage = run.usage if run is not None else NO_USAGE
225
+ usage_reported = run.usage_reported if run is not None else None
226
+ template = getattr(self._settings.dashboard.titles, role.value)
227
+ fields = {
228
+ "task_id": task.id,
229
+ "title": task.title,
230
+ "project": self._settings.project.name,
231
+ "role": role.value,
232
+ "role_label": role.value.capitalize(),
233
+ "glyph": GLYPH_OPERATOR if taken_over else ROLE_GLYPHS[role],
234
+ "agent": effective_rung.agent,
235
+ "model": effective_rung.model,
236
+ "effort": effective_rung.effort if effective_rung.effort is not None else "",
237
+ "pending_rung": pending_rung,
238
+ "iteration": task.iteration,
239
+ "step": task.step.value if task.step is not None else "",
240
+ "status": task.status.value,
241
+ "context": context_title(usage),
242
+ "cost": cost_title(usage),
243
+ "flags": _flags(task),
244
+ "flags_text": _flags(task, taken_over=taken_over, include_glyphs=False),
245
+ }
246
+ cost_color = SECONDARY_TEXT_COLOR
247
+ if usage.cost_usd is not None and usage_reported is not False:
248
+ if usage.cost_basis == COST_BASIS_BILLED:
249
+ cost_color = SAGE
250
+ elif usage.cost_basis == COST_BASIS_API_EQUIVALENT:
251
+ cost_color = AMBER
252
+ await self._decorator.set_pane_title_styling(
253
+ pane_id,
254
+ template,
255
+ fields,
256
+ field_colors={
257
+ "role": ROLE_COLORS[role],
258
+ "role_label": ROLE_COLORS[role],
259
+ "agent": PRIMARY_TEXT_COLOR,
260
+ "model": PRIMARY_TEXT_COLOR,
261
+ "effort": SECONDARY_TEXT_COLOR,
262
+ "pending_rung": SECONDARY_TEXT_COLOR,
263
+ "iteration": SECONDARY_TEXT_COLOR,
264
+ "context": self._context_color(usage, usage_reported),
265
+ "cost": cost_color,
266
+ },
267
+ field_segments={"flags_text": _flag_segments(task, taken_over=taken_over)},
268
+ )
269
+ role_wait = (
270
+ quota.roles.get(role)
271
+ if quota is not None and not taken_over and _quota_shown(task)
272
+ else None
273
+ )
274
+ color, glyph = _pane_state(
275
+ task, role, taken_over=taken_over, quota_blocked=role_wait is not None
276
+ )
277
+ await self._decorator.set_pane_state(pane_id, color=color, glyph=glyph)
278
+ status_path = quota_status_path(run_dir(self._project_root, task.id), role.value)
279
+ if role_wait is None:
280
+ clear_quota_status(status_path)
281
+ else:
282
+ write_quota_status(status_path, role_wait.action, role_wait.at)
283
+
284
+ def _context_color(self, usage: Usage, usage_reported: bool | None) -> str:
285
+ if usage.context_used_pct is None or usage_reported is False:
286
+ return SECONDARY_TEXT_COLOR
287
+ titles = self._settings.dashboard.titles
288
+ level = usage_level(
289
+ usage.context_used_pct,
290
+ warn_pct=titles.context_warn_pct,
291
+ alert_pct=titles.context_alert_pct,
292
+ )
293
+ return USAGE_LEVEL_COLORS[level]
294
+
295
+ def _active_run(self, task_id: str, role: Role) -> Run | None:
296
+ live = self._runs.get_live(task_id, role)
297
+ return live if live is not None else self._runs.get_taken_over(task_id, role)
@@ -0,0 +1,79 @@
1
+ """Persists domain transitions of a task and projects them into events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.events import EventKind
8
+ from shikiru.domain.reports import PlanReport, Verdict
9
+ from shikiru.domain.tasks import Status, TaskOrigin
10
+ from shikiru.engine.events import emit_step_started
11
+ from shikiru.errors import InvariantError
12
+
13
+ if TYPE_CHECKING:
14
+ from shikiru.config.settings import Settings
15
+ from shikiru.domain.steps import Outcome, Transition
16
+ from shikiru.domain.tasks import Task
17
+ from shikiru.engine.titles import TaskTitles
18
+ from shikiru.store.events import EventLog
19
+ from shikiru.store.tasks import TaskStore
20
+
21
+
22
+ class TransitionWriter:
23
+ """The single place a `Transition` becomes stored task state, events and a refreshed title."""
24
+
25
+ def __init__(
26
+ self, *, tasks: TaskStore, events: EventLog, titles: TaskTitles, settings: Settings
27
+ ) -> None:
28
+ """Write transitions to `tasks`, record their events and refresh titles."""
29
+ self._tasks = tasks
30
+ self._events = events
31
+ self._titles = titles
32
+ self._settings = settings
33
+
34
+ async def apply(self, task: Task, transition: Transition, outcome: Outcome | None) -> Task:
35
+ """Apply a task transition and refresh its visible title."""
36
+ updated = self.persist(task, transition, outcome)
37
+ await self._titles.refresh(updated)
38
+ return updated
39
+
40
+ def persist(self, task: Task, transition: Transition, outcome: Outcome | None) -> Task:
41
+ """Write a task transition, emitting its step event, and return the stored task."""
42
+ if transition.enqueue_new_tasks:
43
+ if outcome is None:
44
+ message = f"{task.id}'s transition enqueues new tasks but has no outcome"
45
+ raise InvariantError(message)
46
+ self._apply_new_tasks(task, transition, outcome)
47
+ else:
48
+ self._tasks.apply_transition(task.id, transition)
49
+ if transition.status in (Status.DONE, Status.CANCELLED):
50
+ self._tasks.set_slot(task.id, None)
51
+ if transition.status is Status.ESCALATED:
52
+ self._tasks.set_wait_reason(task.id, None)
53
+ updated = self._tasks.require(task.id)
54
+ if (
55
+ task.step != updated.step
56
+ and updated.step is not None
57
+ and updated.status in (Status.RUNNING, Status.INTEGRATING)
58
+ ):
59
+ emit_step_started(self._events, updated, self._settings)
60
+ return updated
61
+
62
+ def _apply_new_tasks(self, task: Task, transition: Transition, outcome: Outcome) -> None:
63
+ if not isinstance(outcome, PlanReport | Verdict):
64
+ message = f"{task.id}'s transition enqueues new tasks from a {type(outcome).__name__}"
65
+ raise InvariantError(message)
66
+ children = self._tasks.apply_scope_change(
67
+ task.id, transition, outcome.new_tasks, origin=TaskOrigin.LEADER
68
+ )
69
+ if not outcome.new_tasks and not outcome.scope_changes:
70
+ return
71
+ self._events.append(
72
+ EventKind.SCOPE_CHANGE,
73
+ task_id=task.id,
74
+ message=f"{task.id} scope changed: {len(children)} new task(s)",
75
+ data={
76
+ "new_task_ids": [child.id for child in children],
77
+ "scope_changes": list(outcome.scope_changes),
78
+ },
79
+ )
@@ -0,0 +1,206 @@
1
+ """Prompt-context preparation for a task's next turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.domain.reports import FeedbackAction, ReportKind
10
+ from shikiru.domain.tasks import Step
11
+ from shikiru.engine.task_facts import require_repo
12
+ from shikiru.engine.turns import TurnRequest
13
+ from shikiru.errors import InvariantError
14
+ from shikiru.paths import task_root
15
+ from shikiru.prompts.renderer import PromptName
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ from shikiru.config.settings import Settings
21
+ from shikiru.domain.feedback import FeedbackInput
22
+ from shikiru.domain.tasks import Task
23
+ from shikiru.engine.history import ReportHistory
24
+ from shikiru.engine.task_facts import TaskFacts
25
+ from shikiru.store.escalations import EscalationStore
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class NextTurn:
30
+ """How recovery wants the next turn started: as a handoff or as a continuation."""
31
+
32
+ handoff: bool = False
33
+ continue_turn: bool = False
34
+
35
+
36
+ ORDINARY_TURN = NextTurn()
37
+
38
+ _JSON_INDENT = 2
39
+
40
+ FEEDBACK_TEMPLATES = frozenset(
41
+ {PromptName.FEEDBACK_ANALYSIS, PromptName.FEEDBACK_REWORK, PromptName.FEEDBACK_VERIFY}
42
+ )
43
+
44
+
45
+ class ContextKey(StrEnum):
46
+ """The variables the prompt templates read from a turn's context."""
47
+
48
+ REQUEST = "request"
49
+ TASK_ID = "task_id"
50
+ WORKSPACE = "workspace"
51
+ TRACKERS = "trackers"
52
+ REPORTS = "reports"
53
+ QUESTION = "question"
54
+ OPERATOR_DECISION = "operator_decision"
55
+ FACTS = "facts"
56
+ BASE = "base"
57
+ FINAL_ROUND = "final_round"
58
+ CONFLICT = "conflict"
59
+ MODE = "mode"
60
+ FEEDBACK_SNAPSHOT = "feedback_snapshot"
61
+ FEEDBACK_ANALYSIS = "feedback_analysis"
62
+ FEEDBACK_WORK = "feedback_work"
63
+
64
+
65
+ def template_for_step(step: Step, iteration: int) -> tuple[PromptName, ReportKind]:
66
+ """Return the prompt template and report kind of `step`'s ordinary turn."""
67
+ if step is Step.TRIAGE:
68
+ return PromptName.TRIAGE, ReportKind.TRIAGE
69
+ if step is Step.PLAN:
70
+ return PromptName.PLAN, ReportKind.PLAN
71
+ if step is Step.IMPLEMENT:
72
+ template = PromptName.IMPLEMENT if iteration <= 1 else PromptName.REWORK
73
+ return template, ReportKind.IMPLEMENTATION
74
+ if step is Step.CONSULT:
75
+ return PromptName.CONSULT, ReportKind.CONSULT
76
+ if step is Step.VERIFY:
77
+ return PromptName.VERIFY, ReportKind.VERDICT
78
+ if step is Step.INTEGRATE:
79
+ return PromptName.CONFLICTS, ReportKind.IMPLEMENTATION
80
+ message = f"No turn template exists for step {step!r}"
81
+ raise InvariantError(message)
82
+
83
+
84
+ class TurnContextPreparer:
85
+ """Builds each turn's `TurnRequest`."""
86
+
87
+ def __init__(
88
+ self,
89
+ *,
90
+ history: ReportHistory,
91
+ escalation_store: EscalationStore,
92
+ facts: TaskFacts,
93
+ settings: Settings,
94
+ project_root: Path,
95
+ ) -> None:
96
+ """Prepare contexts from report history, operator answers and workspace facts."""
97
+ self._history = history
98
+ self._escalation_store = escalation_store
99
+ self._facts = facts
100
+ self._settings = settings
101
+ self._project_root = project_root
102
+
103
+ async def prepare(
104
+ self,
105
+ task: Task,
106
+ step: Step,
107
+ *,
108
+ session_handoff: bool,
109
+ next_turn: NextTurn = ORDINARY_TURN,
110
+ template_override: PromptName | None = None,
111
+ kind_override: ReportKind | None = None,
112
+ conflict_context: str | None = None,
113
+ feedback: FeedbackInput | None = None,
114
+ ) -> TurnRequest:
115
+ """Build the request of `task`'s next `step` turn."""
116
+ history = self._history.load(task.id)
117
+ continue_turn = next_turn.continue_turn
118
+ if continue_turn:
119
+ template_override = PromptName.CONTINUE
120
+ template, kind = template_for_step(step, task.iteration)
121
+ if template_override is not None:
122
+ template = template_override
123
+ if kind_override is not None:
124
+ kind = kind_override
125
+ context: dict[str, object] = {
126
+ ContextKey.REQUEST: task.request,
127
+ ContextKey.TASK_ID: task.id,
128
+ ContextKey.WORKSPACE: str(task_root(self._project_root, task.id)),
129
+ ContextKey.TRACKERS: list(self._settings.project.trackers),
130
+ ContextKey.REPORTS: self._history.context(history),
131
+ }
132
+ if template is PromptName.CONSULT:
133
+ context[ContextKey.QUESTION] = self._history.latest_question(history)
134
+ context[ContextKey.OPERATOR_DECISION] = (
135
+ self._escalation_store.latest_answer(task.id)
136
+ if task.counters.consults == 0
137
+ else None
138
+ )
139
+ fresh = session_handoff and not continue_turn
140
+ handoff = (fresh and bool(history)) or next_turn.handoff
141
+ needs_facts = (
142
+ template in (PromptName.VERIFY, PromptName.CONFLICTS, *FEEDBACK_TEMPLATES) or handoff
143
+ )
144
+ facts_text = await self._facts.text(task) if needs_facts else ""
145
+ if handoff and template not in FEEDBACK_TEMPLATES:
146
+ template = PromptName.HANDOFF
147
+ if needs_facts:
148
+ context[ContextKey.FACTS] = facts_text
149
+ if template in FEEDBACK_TEMPLATES:
150
+ context.update(self._feedback_context(task, template, feedback))
151
+ if template is PromptName.VERIFY:
152
+ context[ContextKey.BASE] = require_repo(task).base_ref
153
+ context[ContextKey.FINAL_ROUND] = (
154
+ task.iteration >= self._settings.orchestration.max_iterations
155
+ )
156
+ if template is PromptName.CONFLICTS:
157
+ context[ContextKey.CONFLICT] = conflict_context or ""
158
+ return TurnRequest(
159
+ step=step,
160
+ iteration=task.iteration,
161
+ kind=kind,
162
+ template=template,
163
+ context=context,
164
+ facts=facts_text,
165
+ feedback=feedback,
166
+ )
167
+
168
+ @staticmethod
169
+ def _feedback_context(
170
+ task: Task, template: PromptName, feedback: FeedbackInput | None
171
+ ) -> dict[str, object]:
172
+ if feedback is None:
173
+ message = f"{task.id} {template.value} turn has no feedback to work from"
174
+ raise InvariantError(message)
175
+ context: dict[str, object] = {
176
+ ContextKey.MODE: task.mode.value if task.mode is not None else ""
177
+ }
178
+ if template is PromptName.FEEDBACK_ANALYSIS:
179
+ context[ContextKey.FEEDBACK_SNAPSHOT] = feedback.snapshot.model_dump_json(
180
+ indent=_JSON_INDENT
181
+ )
182
+ return context
183
+ analysis = feedback.analysis
184
+ if analysis is None:
185
+ message = f"{task.id} {template.value} turn has no analysis to work from"
186
+ raise InvariantError(message)
187
+ if template is PromptName.FEEDBACK_REWORK:
188
+ addressed = {
189
+ decision.source_id
190
+ for decision in analysis.decisions
191
+ if decision.action is FeedbackAction.ADDRESS
192
+ }
193
+ context[ContextKey.FEEDBACK_SNAPSHOT] = feedback.snapshot.only(
194
+ addressed
195
+ ).model_dump_json(indent=_JSON_INDENT)
196
+ context[ContextKey.FEEDBACK_WORK] = [
197
+ work.model_dump_json(indent=_JSON_INDENT) for work in analysis.work
198
+ ]
199
+ return context
200
+ context[ContextKey.BASE] = require_repo(task).base_ref
201
+ context[ContextKey.FINAL_ROUND] = feedback.final_round
202
+ context[ContextKey.FEEDBACK_SNAPSHOT] = feedback.snapshot.model_dump_json(
203
+ indent=_JSON_INDENT
204
+ )
205
+ context[ContextKey.FEEDBACK_ANALYSIS] = analysis.model_dump_json(indent=_JSON_INDENT)
206
+ return context
@@ -0,0 +1,30 @@
1
+ """The outcome of one executed agent turn, as the flow and its collaborators pass it around."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+ from shikiru.agents.contracts import Rung, Usage
12
+ from shikiru.domain.recovery import TurnResult
13
+ from shikiru.domain.reports import ReportKind
14
+ from shikiru.domain.tasks import Role
15
+
16
+
17
+ @dataclass
18
+ class TurnExecution:
19
+ """A turn's result with the role, rung, directory and usage it ran with."""
20
+
21
+ result: TurnResult
22
+ task_id: str
23
+ role: Role
24
+ turn_dir: Path
25
+ rung: Rung
26
+ run_id: int
27
+ usage_before: Usage
28
+ usage_after: Usage
29
+ kind: ReportKind
30
+ quota_wait_exhausted: bool = False