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,285 @@
1
+ """Shared event construction for engine lifecycle boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.agents.contracts import COST_BASIS_UNKNOWN
8
+ from shikiru.domain.events import EventKind
9
+ from shikiru.domain.ladder import pick_rung
10
+ from shikiru.domain.recovery import Failed, ProtocolError, Stalled
11
+ from shikiru.domain.reports import (
12
+ DECISION_ANSWER,
13
+ STATUS_COMPLETED,
14
+ ConsultAnswer,
15
+ FeedbackAnalysis,
16
+ ImplementationReport,
17
+ PlanReport,
18
+ TriageReport,
19
+ Verdict,
20
+ )
21
+ from shikiru.domain.steps import ACTING_ROLE
22
+
23
+ if TYPE_CHECKING:
24
+ from shikiru.agents.contracts import Rung, Usage
25
+ from shikiru.config.settings import Settings
26
+ from shikiru.domain.recovery import TurnResult
27
+ from shikiru.domain.reports import ReportKind
28
+ from shikiru.domain.steps import Outcome
29
+ from shikiru.domain.tasks import Role, Step, Task
30
+ from shikiru.engine.turn_execution import TurnExecution
31
+ from shikiru.store.events import EventLog
32
+
33
+ TURN_FIELD = "turn"
34
+ ROLE_FIELD = "role"
35
+ ITERATION_FIELD = "iteration"
36
+ REPORT_KIND_FIELD = "report_kind"
37
+
38
+
39
+ def emit_step_started(events: EventLog, task: Task, settings: Settings) -> None:
40
+ """Record the selected role and rung when a task step starts."""
41
+ if task.step is None:
42
+ return
43
+ role = ACTING_ROLE[task.step]
44
+ rung = pick_rung(task, role, settings)
45
+ events.append(
46
+ EventKind.STEP_STARTED,
47
+ task_id=task.id,
48
+ message=f"{task.id} {task.step.value} started",
49
+ data={
50
+ "step": task.step.value,
51
+ "role": role.value,
52
+ "iteration": task.iteration,
53
+ "agent": rung.agent,
54
+ "model": rung.model,
55
+ },
56
+ )
57
+
58
+
59
+ def emit_turn_started(
60
+ events: EventLog,
61
+ task: Task,
62
+ step: Step,
63
+ role: Role,
64
+ rung: Rung,
65
+ *,
66
+ kind: ReportKind,
67
+ run_id: int | None = None,
68
+ turn: str | None = None,
69
+ ) -> None:
70
+ """Record that `role` started its `step` turn on `rung`, owing a report of `kind`."""
71
+ events.append(
72
+ EventKind.TURN_STARTED,
73
+ task_id=task.id,
74
+ message=f"{task.id} {role.value} turn started",
75
+ data={
76
+ "step": step.value,
77
+ "role": role.value,
78
+ "iteration": task.iteration,
79
+ "agent": rung.agent,
80
+ "model": rung.model,
81
+ "effort": rung.effort,
82
+ REPORT_KIND_FIELD: kind.value,
83
+ **_turn_data(turn),
84
+ **({"run_id": run_id} if run_id is not None else {}),
85
+ },
86
+ )
87
+
88
+
89
+ def emit_turn_stopped(
90
+ events: EventLog,
91
+ task: Task,
92
+ step: Step,
93
+ role: Role,
94
+ *,
95
+ rung: Rung,
96
+ run_id: int,
97
+ turn: str | None = None,
98
+ ) -> None:
99
+ """Record that `role`'s `step` turn ended without a result because the task was stopped."""
100
+ events.append(
101
+ EventKind.TURN_STOPPED,
102
+ task_id=task.id,
103
+ message=f"{task.id} {role.value} turn stopped",
104
+ data={
105
+ "step": step.value,
106
+ "role": role.value,
107
+ "iteration": task.iteration,
108
+ "agent": rung.agent,
109
+ "model": rung.model,
110
+ "effort": rung.effort,
111
+ "run_id": run_id,
112
+ **_turn_data(turn),
113
+ },
114
+ )
115
+
116
+
117
+ def emit_nudge(
118
+ events: EventLog, task: Task, step: Step, role: Role, *, turn: str | None = None
119
+ ) -> None:
120
+ """Record that the engine nudged `role`'s stalled `step` turn."""
121
+ events.append(
122
+ EventKind.NUDGE,
123
+ task_id=task.id,
124
+ message=f"{task.id} {role.value} nudge",
125
+ data={"step": step.value, "role": role.value, **_turn_data(turn)},
126
+ )
127
+
128
+
129
+ def emit_turn_failed(
130
+ events: EventLog,
131
+ task: Task,
132
+ step: Step,
133
+ result: TurnResult,
134
+ *,
135
+ rung: Rung,
136
+ run_id: int,
137
+ turn: str | None = None,
138
+ role: Role | None = None,
139
+ ) -> None:
140
+ """Record a turn that ended without a valid report."""
141
+ if isinstance(result, Failed):
142
+ message = result.message
143
+ data: dict[str, object] = {"kind": result.kind.value, "resets_at": result.resets_at}
144
+ elif isinstance(result, Stalled):
145
+ message = "agent stalled"
146
+ data = {}
147
+ elif isinstance(result, ProtocolError):
148
+ message = "agent protocol error"
149
+ data = {}
150
+ else:
151
+ message = type(result).__name__
152
+ data = {}
153
+ data.update(
154
+ {
155
+ "step": step.value,
156
+ "result": type(result).__name__,
157
+ **_provenance_data(role, task.iteration),
158
+ "agent": rung.agent,
159
+ "model": rung.model,
160
+ "effort": rung.effort,
161
+ "run_id": run_id,
162
+ **_turn_data(turn),
163
+ }
164
+ )
165
+ events.append(EventKind.TURN_FAILED, task_id=task.id, message=message, data=data)
166
+
167
+
168
+ def emit_turn_completed(
169
+ events: EventLog, task: Task, outcome: Outcome, execution: TurnExecution
170
+ ) -> None:
171
+ """Record a completed turn and, for a verdict, the verdict itself."""
172
+ summary = _turn_summary(outcome)
173
+ events.append(
174
+ EventKind.TURN_COMPLETED,
175
+ task_id=task.id,
176
+ message=summary,
177
+ data={
178
+ "step": task.step.value if task.step is not None else None,
179
+ "iteration": task.iteration,
180
+ "role": execution.role.value,
181
+ "agent": execution.rung.agent,
182
+ "model": execution.rung.model,
183
+ "effort": execution.rung.effort,
184
+ "run_id": execution.run_id,
185
+ REPORT_KIND_FIELD: execution.kind.value,
186
+ "outcome": _turn_outcome(outcome),
187
+ "summary": summary,
188
+ "question": _turn_question(outcome),
189
+ "usage_delta": _usage_delta(execution.usage_before, execution.usage_after),
190
+ **_turn_data(execution.turn_dir.name),
191
+ },
192
+ )
193
+ if isinstance(outcome, Verdict):
194
+ emit_verdict(
195
+ events,
196
+ task,
197
+ outcome,
198
+ summary,
199
+ turn=execution.turn_dir.name,
200
+ role=execution.role,
201
+ )
202
+
203
+
204
+ def emit_verdict(
205
+ events: EventLog,
206
+ task: Task,
207
+ verdict: Verdict,
208
+ summary: str,
209
+ *,
210
+ turn: str | None = None,
211
+ role: Role | None = None,
212
+ ) -> None:
213
+ """Record the Leader's (or the engine's) verdict on a task."""
214
+ events.append(
215
+ EventKind.VERDICT,
216
+ task_id=task.id,
217
+ message=summary,
218
+ data={
219
+ "decision": verdict.decision,
220
+ "summary": summary,
221
+ "issues": [issue.model_dump() for issue in verdict.issues],
222
+ "residual_risks": list(verdict.residual_risks),
223
+ **_provenance_data(role, task.iteration),
224
+ **_turn_data(turn),
225
+ },
226
+ )
227
+
228
+
229
+ def _turn_data(turn: str | None) -> dict[str, str]:
230
+ return {TURN_FIELD: turn} if turn is not None else {}
231
+
232
+
233
+ def _provenance_data(role: Role | None, iteration: int) -> dict[str, str | int]:
234
+ data: dict[str, str | int] = {ITERATION_FIELD: iteration}
235
+ if role is not None:
236
+ data[ROLE_FIELD] = role.value
237
+ return data
238
+
239
+
240
+ def _turn_summary(outcome: Outcome) -> str:
241
+ if isinstance(outcome, TriageReport):
242
+ return outcome.title
243
+ if isinstance(outcome, ConsultAnswer):
244
+ return outcome.answer if outcome.decision == DECISION_ANSWER else "escalate"
245
+ return outcome.summary
246
+
247
+
248
+ def _turn_question(outcome: Outcome) -> str:
249
+ return outcome.question if isinstance(outcome, ImplementationReport) else ""
250
+
251
+
252
+ def _turn_outcome(outcome: Outcome) -> str:
253
+ if isinstance(outcome, TriageReport):
254
+ return outcome.mode.value
255
+ if isinstance(outcome, PlanReport | FeedbackAnalysis):
256
+ return STATUS_COMPLETED
257
+ if isinstance(outcome, ConsultAnswer):
258
+ return outcome.decision
259
+ if isinstance(outcome, ImplementationReport):
260
+ return outcome.status
261
+ return outcome.decision
262
+
263
+
264
+ def _usage_delta(before: Usage, after: Usage) -> dict[str, int | float | str | None]:
265
+ cost_delta, cost_basis = _comparable_cost_delta(before, after)
266
+ return {
267
+ "input_tokens": after.input_tokens - before.input_tokens,
268
+ "output_tokens": after.output_tokens - before.output_tokens,
269
+ "cache_read_tokens": after.cache_read_tokens - before.cache_read_tokens,
270
+ "cache_write_tokens": after.cache_write_tokens - before.cache_write_tokens,
271
+ "cost_usd": cost_delta,
272
+ "cost_basis": cost_basis,
273
+ }
274
+
275
+
276
+ def _comparable_cost_delta(before: Usage, after: Usage) -> tuple[float | None, str]:
277
+ if (
278
+ before.cost_usd is None
279
+ or after.cost_usd is None
280
+ or before.cost_basis != after.cost_basis
281
+ or after.cost_basis == COST_BASIS_UNKNOWN
282
+ or after.cost_usd < before.cost_usd
283
+ ):
284
+ return None, COST_BASIS_UNKNOWN
285
+ return after.cost_usd - before.cost_usd, after.cost_basis
@@ -0,0 +1,81 @@
1
+ """Gathers the evidence of a task escalation: reports, workspace facts and the turn tail."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.domain.steps import ACTING_ROLE
10
+ from shikiru.errors import ShikiruError
11
+ from shikiru.paths import run_dir
12
+ from shikiru.view_log import read_tail_lines, view_log_path
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+ from shikiru.domain.tasks import Role, Step, Task
18
+ from shikiru.engine.history import ReportHistory
19
+ from shikiru.engine.task_facts import TaskFacts
20
+
21
+ _LOGGER = logging.getLogger(__name__)
22
+
23
+ ESCALATION_PANE_TAIL_LINES = 60
24
+ """Lines of pane scrollback captured as escalation evidence."""
25
+
26
+ FACTS_UNAVAILABLE = "(facts unavailable: {error})"
27
+ HISTORY_UNAVAILABLE = "(report history unavailable, so no reports are shown: {error})"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Evidence:
32
+ """The recorded reports, rendered facts and recent turn signals of one task."""
33
+
34
+ reports: list[dict[str, str]]
35
+ facts: str
36
+ view_tail: str
37
+
38
+
39
+ class EvidenceCollector:
40
+ """Collects the evidence that still exists for a task from its files and git."""
41
+
42
+ def __init__(self, *, history: ReportHistory, facts: TaskFacts, project_root: Path) -> None:
43
+ """Read reports through `history`, facts through `facts` and turn tails under the root."""
44
+ self._history = history
45
+ self._facts = facts
46
+ self._project_root = project_root
47
+
48
+ async def collect(self, task: Task, step: Step | None, role: Role | None = None) -> Evidence:
49
+ """Gather `task`'s evidence; the turn tail is that of `role` or of `step`'s acting role."""
50
+ history_note = ""
51
+ try:
52
+ history = self._history.load(task.id)
53
+ except (OSError, ValueError) as error:
54
+ _LOGGER.exception("could not load report history for escalation of %s", task.id)
55
+ history = []
56
+ history_note = HISTORY_UNAVAILABLE.format(error=error)
57
+ facts = await self._workspace_facts(task)
58
+ return Evidence(
59
+ reports=self._history.context(history),
60
+ facts="\n\n".join(part for part in (history_note, facts) if part),
61
+ view_tail=self._view_tail(task, step, role),
62
+ )
63
+
64
+ async def _workspace_facts(self, task: Task) -> str:
65
+ if task.repo is None:
66
+ return ""
67
+ try:
68
+ return await self._facts.text(task)
69
+ except ShikiruError as error:
70
+ return FACTS_UNAVAILABLE.format(error=error)
71
+
72
+ def _view_tail(self, task: Task, step: Step | None, role_override: Role | None) -> str:
73
+ role = role_override
74
+ if role is None and step is not None:
75
+ role = ACTING_ROLE[step]
76
+ if role is None:
77
+ return ""
78
+ path = view_log_path(run_dir(self._project_root, task.id), role.value)
79
+ if not path.is_file():
80
+ return ""
81
+ return "\n".join(read_tail_lines(path, ESCALATION_PANE_TAIL_LINES))
@@ -0,0 +1,144 @@
1
+ """Records engine faults and their recovery as `engine_fault` and `engine_recovered` events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import traceback
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from datetime import timedelta
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from shikiru.domain.events import EventKind
12
+ from shikiru.domain.faults import (
13
+ COMMAND_KEY,
14
+ DETAIL_KEY,
15
+ ERROR_KEY,
16
+ EXIT_CODE_KEY,
17
+ RECOVERABLE_KEY,
18
+ REPEAT_KEY,
19
+ SCOPE_KEY,
20
+ STDERR_KEY,
21
+ TRACEBACK_KEY,
22
+ )
23
+ from shikiru.errors import CommandFailed, CommandTimeoutError, InvariantError, ShikiruError
24
+ from shikiru.store.database import OperationalDatabaseError
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import AsyncGenerator
28
+ from datetime import datetime
29
+ from logging import Logger
30
+
31
+ from shikiru.clock import Clock
32
+ from shikiru.config.settings import FaultsConfig
33
+ from shikiru.store.events import EventLog
34
+
35
+ RECOVERABLE_FAULTS = (ShikiruError, OSError, OperationalDatabaseError)
36
+ """Environmental faults (a failed tmux, git, file or database call), retried while they last."""
37
+
38
+
39
+ def is_recoverable(error: Exception) -> bool:
40
+ """Return whether `error` is environmental; an `InvariantError` is a logic bug, never one."""
41
+ return isinstance(error, RECOVERABLE_FAULTS) and not isinstance(error, InvariantError)
42
+
43
+
44
+ def _evidence(error: Exception) -> dict[str, Any]:
45
+ evidence: dict[str, Any] = {
46
+ DETAIL_KEY: str(error),
47
+ TRACEBACK_KEY: "".join(traceback.format_exception(error)),
48
+ }
49
+ if isinstance(error, CommandFailed | CommandTimeoutError):
50
+ evidence[COMMAND_KEY] = list(error.argv)
51
+ if isinstance(error, CommandFailed):
52
+ evidence[EXIT_CODE_KEY] = error.returncode
53
+ evidence[STDERR_KEY] = error.stderr
54
+ return evidence
55
+
56
+
57
+ @dataclass
58
+ class _Streak:
59
+ recorded_at: datetime | None = None
60
+ occurrences: int = 0
61
+
62
+
63
+ class FaultReporter:
64
+ """Logs every fault and records one `engine_fault` event per scope, task and error class.
65
+
66
+ A repeat of the same fault is counted and recorded again only after
67
+ `repeat_notify_seconds`, so a persistent or flapping fault stays visible without flooding
68
+ the log or the Operator, and a new error class is recorded at once. Each record carries the
69
+ full error, its traceback and, for a failed command, the command, exit code and stderr.
70
+ """
71
+
72
+ def __init__(
73
+ self, *, events: EventLog, clock: Clock, settings: FaultsConfig, logger: Logger
74
+ ) -> None:
75
+ """Record in `events`, rate-limited with `clock`."""
76
+ self._events = events
77
+ self._clock = clock
78
+ self._settings = settings
79
+ self._logger = logger
80
+ self._streaks: dict[tuple[str, str | None, str], _Streak] = {}
81
+ self._open: set[tuple[str, str | None]] = set()
82
+
83
+ def report(self, error: Exception, *, scope: str, task_id: str | None = None) -> bool:
84
+ """Record `error` raised in `scope` unless just recorded, and classify it.
85
+
86
+ Returns whether it is a recoverable fault.
87
+ """
88
+ recoverable = isinstance(error, RECOVERABLE_FAULTS) and not isinstance(
89
+ error, InvariantError
90
+ )
91
+ kind = type(error).__name__
92
+ now = self._clock.now()
93
+ key = (scope, task_id, kind)
94
+ streak = self._streaks.setdefault(key, _Streak())
95
+ streak.occurrences += 1
96
+ interval = timedelta(seconds=self._settings.repeat_notify_seconds)
97
+ if streak.recorded_at is None or now - streak.recorded_at >= interval:
98
+ try:
99
+ self._events.append(
100
+ EventKind.ENGINE_FAULT,
101
+ task_id=task_id,
102
+ message=f"{scope} failed: {kind}: {error}",
103
+ data={
104
+ SCOPE_KEY: scope,
105
+ ERROR_KEY: kind,
106
+ RECOVERABLE_KEY: recoverable,
107
+ REPEAT_KEY: streak.occurrences,
108
+ **_evidence(error),
109
+ },
110
+ )
111
+ except OperationalDatabaseError:
112
+ self._logger.exception("could not record the fault of %s", scope)
113
+ return recoverable
114
+ streak.recorded_at = now
115
+ streak.occurrences = 0
116
+ self._open.add((scope, task_id))
117
+ return recoverable
118
+
119
+ def resolve(self, scope: str, *, task_id: str | None = None) -> None:
120
+ """Record an `engine_recovered` event when `scope` failed, was recorded, and now works."""
121
+ if (scope, task_id) not in self._open:
122
+ return
123
+ try:
124
+ self._events.append(
125
+ EventKind.ENGINE_RECOVERED,
126
+ task_id=task_id,
127
+ message=f"{scope} recovered",
128
+ data={SCOPE_KEY: scope},
129
+ )
130
+ except OperationalDatabaseError:
131
+ self._logger.exception("could not record the recovery of %s", scope)
132
+ return
133
+ self._open.remove((scope, task_id))
134
+
135
+ @asynccontextmanager
136
+ async def isolate(self, scope: str, *, task_id: str | None = None) -> AsyncGenerator[None]:
137
+ """Absorb any fault of the block: log it with its traceback and record it for `task_id`."""
138
+ try:
139
+ yield
140
+ except Exception as error:
141
+ self._logger.exception("%s failed", scope)
142
+ self.report(error, scope=scope, task_id=task_id)
143
+ else:
144
+ self.resolve(scope, task_id=task_id)