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,271 @@
1
+ """Human one-line phrases for recorded engine events and normalized agent signals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from typing import TYPE_CHECKING, cast
10
+
11
+ from shikiru.agents.contracts import (
12
+ Compacted,
13
+ Message,
14
+ QuotaReported,
15
+ SessionStarted,
16
+ ToolFinished,
17
+ ToolOutcome,
18
+ ToolStarted,
19
+ TurnEnded,
20
+ TurnFailed,
21
+ UsageReported,
22
+ )
23
+ from shikiru.domain.events import EventKind
24
+
25
+ if TYPE_CHECKING:
26
+ from shikiru.domain.events import Event
27
+
28
+ GLYPH_OK = "\N{CHECK MARK}"
29
+ GLYPH_FAIL = "\N{BALLOT X}"
30
+ GLYPH_ATTENTION = "!"
31
+ GLYPH_ALARM = "\N{DOUBLE EXCLAMATION MARK}\N{VARIATION SELECTOR-15}"
32
+ NO_GLYPH = ""
33
+ PHRASE_SEPARATOR = " · "
34
+ SIGNAL_MESSAGE = Message.__name__
35
+ SIGNAL_TOOL_STARTED = ToolStarted.__name__
36
+ SIGNAL_TOOL_FINISHED = ToolFinished.__name__
37
+ SIGNAL_TURN_ENDED = TurnEnded.__name__
38
+ SIGNAL_TURN_FAILED = TurnFailed.__name__
39
+ _BOOKKEEPING = frozenset(
40
+ {
41
+ SessionStarted.__name__,
42
+ UsageReported.__name__,
43
+ QuotaReported.__name__,
44
+ Compacted.__name__,
45
+ }
46
+ )
47
+ _WORD_SEPARATOR = " "
48
+ _QUOTE = '"'
49
+ _ROUND_WORD = "round"
50
+ _LIST_SEPARATOR = ", "
51
+ _EXIT_LABEL = "exit"
52
+ _SUCCESS_EXIT_CODE = 0
53
+ _TURN_ENDED_LABEL = "turn ended"
54
+ _TASK_ID_SEPARATOR = " "
55
+ _DETAIL_FIELDS = ("command", "file_path", "path", "pattern", "url", "query")
56
+ _TOOL_VERBS = {
57
+ "Bash": "ran",
58
+ "command": "ran",
59
+ "Edit": "edited",
60
+ "MultiEdit": "edited",
61
+ "Write": "edited",
62
+ "NotebookEdit": "edited",
63
+ "file_change": "edited",
64
+ "Read": "read",
65
+ "Grep": "searched",
66
+ "Glob": "searched",
67
+ }
68
+
69
+
70
+ class Tone(StrEnum):
71
+ """How strongly a phrase should stand out."""
72
+
73
+ PLAIN = "plain"
74
+ SUCCESS = "success"
75
+ FAILURE = "failure"
76
+ WARNING = "warning"
77
+ CRITICAL = "critical"
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Phrase:
82
+ """A one-line human description with its status glyph and tone."""
83
+
84
+ text: str
85
+ glyph: str = NO_GLYPH
86
+ tone: Tone = Tone.PLAIN
87
+
88
+
89
+ type Detail = Callable[[Event], str]
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class _EventSpec:
94
+ label: str
95
+ glyph: str = NO_GLYPH
96
+ tone: Tone = Tone.PLAIN
97
+ detail: Detail | None = None
98
+
99
+
100
+ def _field(key: str) -> Detail:
101
+ def read(event: Event) -> str:
102
+ value = event.data.get(key)
103
+ return "" if value is None else str(value)
104
+
105
+ return read
106
+
107
+
108
+ def _message(event: Event) -> str:
109
+ prefix = f"{event.task_id}{_TASK_ID_SEPARATOR}" if event.task_id else ""
110
+ return event.message.removeprefix(prefix)
111
+
112
+
113
+ def _step_round(event: Event) -> str:
114
+ parts = [str(event.data.get("step", ""))]
115
+ iteration = event.data.get("iteration")
116
+ if isinstance(iteration, int) and iteration >= 1:
117
+ parts.append(f"{_ROUND_WORD} {iteration}")
118
+ return _LIST_SEPARATOR.join(part for part in parts if part)
119
+
120
+
121
+ def _rung_change(event: Event) -> str:
122
+ moved = f"{event.data.get('from')} to {event.data.get('to')}"
123
+ return PHRASE_SEPARATOR.join(part for part in (_field("role")(event), moved) if part)
124
+
125
+
126
+ def _new_tasks(event: Event) -> str:
127
+ count = len(event.data.get("new_task_ids") or ())
128
+ return f"{count} new" if count else ""
129
+
130
+
131
+ _VERDICTS = {
132
+ "accept": Phrase("accepted", GLYPH_OK, Tone.SUCCESS),
133
+ "changes_requested": Phrase("changes requested", GLYPH_FAIL, Tone.FAILURE),
134
+ "escalate": Phrase("escalate", GLYPH_ATTENTION, Tone.WARNING),
135
+ "supersede": Phrase("superseded"),
136
+ }
137
+ _EVENTS: dict[str, _EventSpec] = {
138
+ EventKind.TASK_QUEUED: _EventSpec("task queued"),
139
+ EventKind.TASK_STARTED: _EventSpec("task started", detail=_field("slot")),
140
+ EventKind.DISPATCH_FAILED: _EventSpec(
141
+ "dispatch failed", GLYPH_FAIL, Tone.FAILURE, _field("error")
142
+ ),
143
+ EventKind.STEP_STARTED: _EventSpec("step started", detail=_step_round),
144
+ EventKind.TURN_STARTED: _EventSpec("turn started", detail=_field("role")),
145
+ EventKind.TURN_COMPLETED: _EventSpec("turn completed", GLYPH_OK, Tone.SUCCESS, _field("role")),
146
+ EventKind.TURN_FAILED: _EventSpec("turn failed", GLYPH_FAIL, Tone.FAILURE, _message),
147
+ EventKind.TURN_STOPPED: _EventSpec("turn stopped", detail=_field("role")),
148
+ EventKind.NUDGE: _EventSpec("nudge sent", GLYPH_ATTENTION, Tone.WARNING, _field("role")),
149
+ EventKind.HELPER_ACTION: _EventSpec("helper", GLYPH_ATTENTION, Tone.WARNING, _message),
150
+ EventKind.RUNG_CHANGED: _EventSpec("rung changed", GLYPH_ATTENTION, Tone.WARNING, _rung_change),
151
+ EventKind.COMPACTED: _EventSpec("context compacted", detail=_field("role")),
152
+ EventKind.QUOTA_BLOCKED: _EventSpec(
153
+ "quota blocked", GLYPH_ATTENTION, Tone.WARNING, _field("agent")
154
+ ),
155
+ EventKind.QUOTA_CLEARED: _EventSpec("quota cleared", detail=_field("agent")),
156
+ EventKind.SCOPE_CHANGE: _EventSpec("scope changed", detail=_new_tasks),
157
+ EventKind.HOLD_SET: _EventSpec("hold set", detail=_field("hold")),
158
+ EventKind.HOLD_CLEARED: _EventSpec("hold cleared", detail=_field("hold")),
159
+ EventKind.TAKEN_OVER: _EventSpec("taken over", detail=_field("role")),
160
+ EventKind.HANDED_BACK: _EventSpec("handed back", detail=_field("role")),
161
+ EventKind.TAKEOVER_LOST: _EventSpec(
162
+ "takeover lost", GLYPH_ATTENTION, Tone.WARNING, _field("role")
163
+ ),
164
+ EventKind.OPERATOR_MESSAGE: _EventSpec("operator message", detail=_field("role")),
165
+ EventKind.ESCALATION: _EventSpec("escalated", GLYPH_ALARM, Tone.CRITICAL, _message),
166
+ EventKind.ESCALATION_RESOLVED: _EventSpec(
167
+ "escalation resolved", GLYPH_OK, Tone.SUCCESS, _field("resolution")
168
+ ),
169
+ EventKind.INTEGRATION_STARTED: _EventSpec("integration started"),
170
+ EventKind.INTEGRATION_APPLYING: _EventSpec("integration applying"),
171
+ EventKind.INTEGRATION_WAITING: _EventSpec(
172
+ "integration waiting", GLYPH_ATTENTION, Tone.WARNING, _message
173
+ ),
174
+ EventKind.PR_OPENED: _EventSpec("PR opened", GLYPH_OK, Tone.SUCCESS, _field("url")),
175
+ EventKind.PR_WITHDRAWN: _EventSpec("PR withdrawn", detail=_field("url")),
176
+ EventKind.TASK_DONE: _EventSpec("task done", GLYPH_OK, Tone.SUCCESS, _message),
177
+ EventKind.TASK_RETRIED: _EventSpec("task retried"),
178
+ EventKind.TASK_CANCELLED: _EventSpec("task cancelled", GLYPH_FAIL, Tone.FAILURE),
179
+ EventKind.PANE_RESTORED: _EventSpec("pane restored", detail=_field("pane")),
180
+ EventKind.ENGINE_FAULT: _EventSpec("engine fault", GLYPH_FAIL, Tone.FAILURE, _message),
181
+ }
182
+
183
+
184
+ def kind_phrase(kind: str) -> Phrase | None:
185
+ """Return the label, glyph and tone of an engine event kind, or None when it is unlisted."""
186
+ spec = _EVENTS.get(kind)
187
+ return None if spec is None else Phrase(spec.label, spec.glyph, spec.tone)
188
+
189
+
190
+ def verdict_phrase(decision: str) -> Phrase | None:
191
+ """Return the phrase of a verdict decision, or None when the decision is unknown."""
192
+ return _VERDICTS.get(decision)
193
+
194
+
195
+ def _joined(label: str, detail: str) -> str:
196
+ return f"{label}{PHRASE_SEPARATOR}{detail}" if detail else label
197
+
198
+
199
+ def event_phrase(event: Event) -> Phrase:
200
+ """Describe one engine event in a human phrase; an unlisted kind shows its recorded message."""
201
+ if event.kind is EventKind.VERDICT:
202
+ verdict = _VERDICTS.get(str(event.data.get("decision")))
203
+ if verdict is None:
204
+ return Phrase(_joined("verdict", event.message))
205
+ return Phrase(_joined("verdict", verdict.text), verdict.glyph, verdict.tone)
206
+ spec = _EVENTS.get(event.kind)
207
+ if spec is None:
208
+ return Phrase(event.message)
209
+ detail = spec.detail(event) if spec.detail is not None else ""
210
+ return Phrase(_joined(spec.label, detail), spec.glyph, spec.tone)
211
+
212
+
213
+ def tool_target(detail: str) -> str:
214
+ """Return the command, path or query inside a tool's JSON detail, else the detail itself."""
215
+ try:
216
+ decoded: object = json.loads(detail)
217
+ except json.JSONDecodeError:
218
+ return detail
219
+ if not isinstance(decoded, dict):
220
+ return detail
221
+ fields = cast("dict[str, object]", decoded)
222
+ for key in _DETAIL_FIELDS:
223
+ value = fields.get(key)
224
+ if isinstance(value, str) and value:
225
+ return value
226
+ return detail
227
+
228
+
229
+ def _tool_started(data: Mapping[str, object]) -> Phrase:
230
+ name = str(data.get("name", ""))
231
+ target = tool_target(str(data.get("detail") or ""))
232
+ verb = _TOOL_VERBS.get(name, name)
233
+ return Phrase(" ".join(part for part in (verb, target) if part))
234
+
235
+
236
+ def _tool_finished(data: Mapping[str, object]) -> Phrase | None:
237
+ outcome = data.get("outcome")
238
+ exit_code = data.get("exit_code")
239
+ has_exit = type(exit_code) is int
240
+ if outcome not in (ToolOutcome.SUCCESS.value, ToolOutcome.FAILURE.value) and not has_exit:
241
+ return None
242
+ failed = outcome == ToolOutcome.FAILURE.value or (
243
+ has_exit and exit_code != _SUCCESS_EXIT_CODE and outcome != ToolOutcome.SUCCESS.value
244
+ )
245
+ label = f"{_EXIT_LABEL} {exit_code}" if has_exit else str(outcome)
246
+ glyph, tone = (GLYPH_FAIL, Tone.FAILURE) if failed else (GLYPH_OK, Tone.SUCCESS)
247
+ return Phrase(_joined(str(data.get("name", "")), label), glyph, tone)
248
+
249
+
250
+ def _message_text(data: Mapping[str, object], key: str) -> str:
251
+ return _WORD_SEPARATOR.join(str(data.get(key) or "").split())
252
+
253
+
254
+ def signal_phrase(signal: str, data: Mapping[str, object]) -> Phrase | None:
255
+ """Describe one normalized agent signal; bookkeeping and empty signals have no phrase (None)."""
256
+ if signal == SIGNAL_MESSAGE:
257
+ text = _message_text(data, "text")
258
+ return Phrase(f"{_QUOTE}{text}{_QUOTE}") if text else None
259
+ if signal == SIGNAL_TOOL_STARTED:
260
+ return _tool_started(data)
261
+ if signal == SIGNAL_TOOL_FINISHED:
262
+ return _tool_finished(data)
263
+ if signal == SIGNAL_TURN_ENDED:
264
+ return Phrase(
265
+ _joined(_TURN_ENDED_LABEL, _message_text(data, "last_message")), GLYPH_OK, Tone.SUCCESS
266
+ )
267
+ if signal == SIGNAL_TURN_FAILED:
268
+ return Phrase(
269
+ f"{data.get('kind')}: {_message_text(data, 'message')}", GLYPH_FAIL, Tone.FAILURE
270
+ )
271
+ return None if signal in _BOOKKEEPING else Phrase(signal)
@@ -0,0 +1,18 @@
1
+ """Reading and comparing the version a tool prints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)")
8
+
9
+ Version = tuple[int, int]
10
+
11
+
12
+ def parse_version(text: str) -> Version | None:
13
+ """Return the first `major.minor` in `text`, ignoring suffixes like the `a` of `3.3a`."""
14
+ found = VERSION_PATTERN.search(text)
15
+ if found is None:
16
+ return None
17
+ major, minor = found.groups()
18
+ return int(major), int(minor)
@@ -0,0 +1,372 @@
1
+ """Pure recorded workflow history projection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum, auto
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.domain.events import Event, EventKind
10
+ from shikiru.domain.reports import (
11
+ DECISION_ACCEPT,
12
+ DECISION_ANSWER,
13
+ DECISION_CHANGES_REQUESTED,
14
+ STATUS_COMPLETED,
15
+ ConsultAnswer,
16
+ ImplementationReport,
17
+ PlanReport,
18
+ Report,
19
+ ReportKind,
20
+ TriageReport,
21
+ Verdict,
22
+ )
23
+ from shikiru.domain.tasks import Mode, Status, Step, Task
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Sequence
27
+
28
+ WORKFLOW_STEP_KEY = "step"
29
+ WORKFLOW_ITERATION_KEY = "iteration"
30
+ WORKFLOW_TURN_KEY = "turn"
31
+ WORKFLOW_DECISION_KEY = "decision"
32
+
33
+
34
+ class WorkflowMilestoneState(StrEnum):
35
+ """Recorded semantic outcome for one workflow stage."""
36
+
37
+ DONE = auto()
38
+ CURRENT = auto()
39
+ LATER = auto()
40
+ SKIPPED = auto()
41
+ REWORK = auto()
42
+ UNKNOWN = auto()
43
+ STOPPED = auto()
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class WorkflowReport:
48
+ """One already-validated agent report with its retained turn identity."""
49
+
50
+ turn: str
51
+ kind: ReportKind
52
+ data: Report
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class WorkflowMilestone:
57
+ """One stage and its recorded workflow outcome."""
58
+
59
+ stage: Step
60
+ state: WorkflowMilestoneState
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class WorkflowProgress:
65
+ """Milestones projected from bounded task history and its completeness signal."""
66
+
67
+ milestones: tuple[WorkflowMilestone, ...]
68
+ history_complete: bool
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class _StepEntry:
73
+ event_index: int
74
+ event_id: int
75
+ step: Step | None
76
+ iteration: int | None
77
+
78
+
79
+ def project_workflow(
80
+ task: Task,
81
+ events: Sequence[Event],
82
+ reports: Sequence[WorkflowReport],
83
+ *,
84
+ event_window_complete: bool,
85
+ ) -> WorkflowProgress:
86
+ """Project factual milestones from retained transitions and validated agent reports."""
87
+ history_complete = (
88
+ event_window_complete and bool(events) and events[0].kind is EventKind.TASK_QUEUED
89
+ )
90
+ entries = _step_entries(events)
91
+ entered_steps = {entry.step for entry in entries if entry.step is not None}
92
+ first_entered_step = entries[0].step if entries else None
93
+ reported_steps = {
94
+ step for report in reports if (step := _report_stage(report.kind)) is not None
95
+ }
96
+ completed_reports = _completed_reports(reports, events, entries)
97
+ stages = _workflow_stages(task, entered_steps, reported_steps, entries)
98
+ if not stages:
99
+ return WorkflowProgress((), history_complete)
100
+ recorded = entered_steps | reported_steps
101
+ settled = _settled_milestones(task, stages, recorded)
102
+ if settled is not None:
103
+ return WorkflowProgress(settled, history_complete)
104
+ stage_positions = {step: index for index, step in enumerate(stages)}
105
+ current_position = stage_positions.get(task.step) if task.step is not None else None
106
+ last_entries = _last_step_entries(entries)
107
+ verdicts = _verdicts(events)
108
+
109
+ def milestone_state(step: Step) -> WorkflowMilestoneState:
110
+ if step is task.step:
111
+ state = WorkflowMilestoneState.CURRENT
112
+ elif (
113
+ step is Step.TRIAGE
114
+ and history_complete
115
+ and first_entered_step in {Step.PLAN, Step.IMPLEMENT}
116
+ ):
117
+ state = WorkflowMilestoneState.SKIPPED
118
+ elif step in entered_steps | reported_steps:
119
+ latest_entry = last_entries.get(step)
120
+ later_steps = {
121
+ entry.step
122
+ for entry in entries
123
+ if latest_entry is not None
124
+ and entry.step is not None
125
+ and entry.event_index > latest_entry.event_index
126
+ }
127
+ rework_requested = step is Step.VERIFY and any(
128
+ index > (latest_entry.event_index if latest_entry is not None else -1)
129
+ and decision == DECISION_CHANGES_REQUESTED
130
+ for index, decision in verdicts
131
+ )
132
+ if rework_requested:
133
+ state = WorkflowMilestoneState.REWORK
134
+ elif _completed_by_later_step(step, later_steps) or _report_completes_entered_stage(
135
+ step,
136
+ latest_entry.event_id if latest_entry is not None else None,
137
+ completed_reports,
138
+ ):
139
+ state = WorkflowMilestoneState.DONE
140
+ else:
141
+ state = WorkflowMilestoneState.UNKNOWN
142
+ elif current_position is not None and stage_positions[step] > current_position:
143
+ state = WorkflowMilestoneState.LATER
144
+ else:
145
+ state = WorkflowMilestoneState.UNKNOWN
146
+ return state
147
+
148
+ milestones = tuple(
149
+ WorkflowMilestone(
150
+ stage=step,
151
+ state=milestone_state(step),
152
+ )
153
+ for step in stages
154
+ )
155
+ return WorkflowProgress(milestones, history_complete)
156
+
157
+
158
+ def _settled_milestones(
159
+ task: Task, stages: tuple[Step, ...], recorded: set[Step]
160
+ ) -> tuple[WorkflowMilestone, ...] | None:
161
+ if task.status in (Status.DONE, Status.CANCELLED):
162
+ return _terminal_milestones(task, stages, recorded)
163
+ if task.step is None and not recorded:
164
+ return tuple(WorkflowMilestone(step, WorkflowMilestoneState.LATER) for step in stages)
165
+ return None
166
+
167
+
168
+ def _terminal_milestones(
169
+ task: Task, stages: tuple[Step, ...], recorded: set[Step]
170
+ ) -> tuple[WorkflowMilestone, ...]:
171
+ def state(step: Step) -> WorkflowMilestoneState:
172
+ if task.status is Status.CANCELLED and step is task.step:
173
+ return WorkflowMilestoneState.STOPPED
174
+ if step in recorded or (step is Step.INTEGRATE and task.status is Status.DONE):
175
+ return WorkflowMilestoneState.DONE
176
+ return WorkflowMilestoneState.SKIPPED
177
+
178
+ return tuple(WorkflowMilestone(step, state(step)) for step in stages)
179
+
180
+
181
+ def _step_entries(events: Sequence[Event]) -> list[_StepEntry]:
182
+ entries: list[_StepEntry] = []
183
+ for index, event in enumerate(events):
184
+ value = event.data.get(WORKFLOW_STEP_KEY)
185
+ if event.kind is not EventKind.STEP_STARTED:
186
+ continue
187
+ try:
188
+ step = Step(value) if isinstance(value, str) else None
189
+ except ValueError:
190
+ step = None
191
+ iteration = event.data.get(WORKFLOW_ITERATION_KEY)
192
+ entries.append(
193
+ _StepEntry(
194
+ index,
195
+ event.id,
196
+ step,
197
+ iteration if type(iteration) is int else None,
198
+ )
199
+ )
200
+ return entries
201
+
202
+
203
+ def _completed_reports(
204
+ reports: Sequence[WorkflowReport],
205
+ events: Sequence[Event],
206
+ entries: list[_StepEntry],
207
+ ) -> set[tuple[Step, int]]:
208
+ report_entries: dict[str, tuple[Step, int]] = {}
209
+ for index, event in enumerate(events):
210
+ turn = event.data.get(WORKFLOW_TURN_KEY)
211
+ step_value = event.data.get(WORKFLOW_STEP_KEY)
212
+ iteration = event.data.get(WORKFLOW_ITERATION_KEY)
213
+ if (
214
+ event.kind is EventKind.TURN_STARTED
215
+ and isinstance(turn, str)
216
+ and isinstance(step_value, str)
217
+ and type(iteration) is int
218
+ ):
219
+ try:
220
+ step = Step(step_value)
221
+ except ValueError:
222
+ continue
223
+ if not entries:
224
+ continue
225
+ latest_entry = next(
226
+ (entry for entry in reversed(entries) if entry.event_index < index),
227
+ None,
228
+ )
229
+ if latest_entry is None or (latest_entry.step, latest_entry.iteration) != (
230
+ step,
231
+ iteration,
232
+ ):
233
+ continue
234
+ report_entries[turn] = (step, latest_entry.event_id)
235
+ completed: set[tuple[Step, int]] = set()
236
+ for report in reports:
237
+ stage = _report_stage(report.kind)
238
+ if stage is None or not _report_completes_stage(report.data):
239
+ continue
240
+ report_entry = report_entries.get(report.turn)
241
+ if report_entry is not None and report_entry[0] is stage:
242
+ completed.add((stage, report_entry[1]))
243
+ return completed
244
+
245
+
246
+ def _last_step_entries(
247
+ entries: list[_StepEntry],
248
+ ) -> dict[Step, _StepEntry]:
249
+ last_entries: dict[Step, _StepEntry] = {}
250
+ for entry in entries:
251
+ if entry.step is not None:
252
+ last_entries[entry.step] = entry
253
+ return last_entries
254
+
255
+
256
+ def _verdicts(events: Sequence[Event]) -> list[tuple[int, object]]:
257
+ return [
258
+ (index, event.data.get(WORKFLOW_DECISION_KEY))
259
+ for index, event in enumerate(events)
260
+ if event.kind is EventKind.VERDICT
261
+ ]
262
+
263
+
264
+ def _workflow_stages(
265
+ task: Task,
266
+ entered_steps: set[Step],
267
+ reported_steps: set[Step],
268
+ entries: list[_StepEntry],
269
+ ) -> tuple[Step, ...]:
270
+ observed_steps = entered_steps | reported_steps
271
+ if task.mode is Mode.PLAN or task.step is Step.PLAN or Step.PLAN in observed_steps:
272
+ stages = [Step.TRIAGE, Step.PLAN, Step.INTEGRATE]
273
+ if Step.CONSULT in observed_steps or task.step is Step.CONSULT:
274
+ stages.append(Step.CONSULT)
275
+ if Step.VERIFY in entered_steps or task.step is Step.VERIFY:
276
+ stages.append(Step.VERIFY)
277
+ return tuple(stages)
278
+ if (
279
+ task.mode is Mode.IMPLEMENT
280
+ or task.step
281
+ in {
282
+ Step.IMPLEMENT,
283
+ Step.CONSULT,
284
+ Step.VERIFY,
285
+ Step.INTEGRATE,
286
+ }
287
+ or observed_steps & {Step.IMPLEMENT, Step.CONSULT, Step.VERIFY, Step.INTEGRATE}
288
+ ):
289
+ stages = [Step.TRIAGE, Step.IMPLEMENT, Step.VERIFY, Step.INTEGRATE]
290
+ elif task.step is Step.TRIAGE:
291
+ stages = [Step.TRIAGE]
292
+ else:
293
+ return ()
294
+ if Step.CONSULT in observed_steps or task.step is Step.CONSULT:
295
+ if _consult_follows_integration(task, entries):
296
+ stages.append(Step.CONSULT)
297
+ else:
298
+ stages.insert(stages.index(Step.IMPLEMENT) + 1, Step.CONSULT)
299
+ return tuple(stages)
300
+
301
+
302
+ def _consult_follows_integration(task: Task, entries: list[_StepEntry]) -> bool:
303
+ if task.step is Step.CONSULT and task.status is Status.INTEGRATING:
304
+ return True
305
+ for index in range(len(entries) - 1, -1, -1):
306
+ entry = entries[index]
307
+ if entry.step is not Step.CONSULT or entry.iteration != task.iteration:
308
+ continue
309
+ if index == 0:
310
+ return False
311
+ previous = entries[index - 1]
312
+ return previous.step is Step.INTEGRATE and previous.iteration == task.iteration
313
+ if task.step is Step.CONSULT and entries:
314
+ latest = entries[-1]
315
+ return latest.step is Step.INTEGRATE and latest.iteration == task.iteration
316
+ return False
317
+
318
+
319
+ def _completed_by_later_step(stage: Step, later_steps: set[Step]) -> bool:
320
+ match stage:
321
+ case Step.TRIAGE:
322
+ return bool(later_steps & {Step.PLAN, Step.IMPLEMENT})
323
+ case Step.PLAN:
324
+ return Step.INTEGRATE in later_steps
325
+ case Step.IMPLEMENT:
326
+ return bool(later_steps & {Step.VERIFY, Step.CONSULT})
327
+ case Step.CONSULT:
328
+ return bool(later_steps & {Step.IMPLEMENT, Step.INTEGRATE})
329
+ case Step.VERIFY:
330
+ return Step.INTEGRATE in later_steps
331
+ case Step.INTEGRATE:
332
+ return False
333
+
334
+
335
+ def _report_stage(kind: ReportKind) -> Step | None:
336
+ match kind:
337
+ case ReportKind.TRIAGE:
338
+ return Step.TRIAGE
339
+ case ReportKind.PLAN:
340
+ return Step.PLAN
341
+ case ReportKind.IMPLEMENTATION:
342
+ return Step.IMPLEMENT
343
+ case ReportKind.CONSULT:
344
+ return Step.CONSULT
345
+ case ReportKind.VERDICT:
346
+ return Step.VERIFY
347
+ case (
348
+ ReportKind.FEEDBACK_ANALYSIS | ReportKind.FEEDBACK_REWORK | ReportKind.FEEDBACK_VERDICT
349
+ ):
350
+ return None
351
+
352
+
353
+ def _report_completes_stage(report: Report) -> bool:
354
+ match report:
355
+ case TriageReport() | PlanReport():
356
+ return True
357
+ case ImplementationReport(status=status) if status == STATUS_COMPLETED:
358
+ return True
359
+ case ConsultAnswer(decision=decision) if decision == DECISION_ANSWER:
360
+ return True
361
+ case Verdict(decision=decision) if decision == DECISION_ACCEPT:
362
+ return True
363
+ case _:
364
+ return False
365
+
366
+
367
+ def _report_completes_entered_stage(
368
+ stage: Step,
369
+ entered_identity: int | None,
370
+ completed_reports: set[tuple[Step, int]],
371
+ ) -> bool:
372
+ return entered_identity is not None and (stage, entered_identity) in completed_reports
File without changes