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,110 @@
1
+ """Files a task flow's escalation with its gathered evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.recovery import Failed, ProtocolError, Stalled
8
+ from shikiru.domain.reports import ConsultAnswer, ImplementationReport, Verdict
9
+ from shikiru.domain.tasks import EscalationReason, Status
10
+ from shikiru.errors import InvariantError
11
+
12
+ if TYPE_CHECKING:
13
+ from shikiru.domain.recovery import TurnResult
14
+ from shikiru.domain.steps import Outcome, Transition
15
+ from shikiru.domain.tasks import Role, Task
16
+ from shikiru.engine.escalations import EscalationRaiser
17
+ from shikiru.engine.evidence import EvidenceCollector
18
+ from shikiru.engine.transitions import TransitionWriter
19
+ from shikiru.store.tasks import TaskStore
20
+
21
+
22
+ def escalation_narrative(
23
+ reason: EscalationReason,
24
+ outcome: Outcome | None,
25
+ result: TurnResult | None = None,
26
+ ) -> tuple[str, str]:
27
+ """Return the default question and the attempted-recovery text of an escalation."""
28
+ question = "An Operator decision is needed to continue."
29
+ attempted = "Automatic recovery has no option left"
30
+ if reason is EscalationReason.ITERATIONS_EXHAUSTED:
31
+ question = (
32
+ "The final round still requested changes; give final-round guidance, accept or cancel."
33
+ )
34
+ attempted = "Implement and verify cycled to the configured iteration limit."
35
+ elif reason is EscalationReason.ENGINE_ERROR:
36
+ attempted = "The engine raised an error no automatic recovery covers."
37
+ elif isinstance(outcome, ConsultAnswer) and outcome.escalation is not None:
38
+ question = outcome.escalation.question
39
+ attempted = "The Leader escalated during a consult turn."
40
+ elif isinstance(outcome, Verdict) and outcome.escalation is not None:
41
+ question = outcome.escalation.question
42
+ attempted = "The Leader escalated during verification."
43
+ elif isinstance(outcome, ImplementationReport):
44
+ question = outcome.question
45
+ attempted = "The Implementer's question exceeded the consult limit."
46
+ elif isinstance(result, Failed):
47
+ question = result.message
48
+ attempted = f"The agent reported a {result.kind.value} failure."
49
+ elif isinstance(result, Stalled):
50
+ question = "The agent's turn stream stopped progressing."
51
+ attempted = "Automatic stall recovery was exhausted."
52
+ elif isinstance(result, ProtocolError):
53
+ question = "The agent did not produce a valid report."
54
+ attempted = "The Helper could not rebuild it."
55
+ elif result is not None:
56
+ question = "The agent session could not continue."
57
+ attempted = f"Recovery ended with {type(result).__name__}."
58
+ return question, attempted
59
+
60
+
61
+ class EscalationFiler:
62
+ """Collects the evidence, then persists the escalated state and records the escalation."""
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ raiser: EscalationRaiser,
68
+ evidence: EvidenceCollector,
69
+ writer: TransitionWriter,
70
+ tasks: TaskStore,
71
+ ) -> None:
72
+ """Record escalations through `raiser` with evidence from `evidence`, state via `writer`."""
73
+ self._raiser = raiser
74
+ self._evidence = evidence
75
+ self._writer = writer
76
+ self._tasks = tasks
77
+
78
+ async def escalate(
79
+ self,
80
+ task: Task,
81
+ transition: Transition,
82
+ outcome: Outcome | None,
83
+ *,
84
+ question: str | None = None,
85
+ result: TurnResult | None = None,
86
+ role: Role | None = None,
87
+ ) -> Task:
88
+ """Apply the escalating `transition` with its packet and return the stored task."""
89
+ reason = transition.escalation_reason
90
+ if transition.status is not Status.ESCALATED or reason is None:
91
+ message = f"{task.id} escalation has no escalated status or reason"
92
+ raise InvariantError(message)
93
+ narrative_question, attempted = escalation_narrative(reason, outcome, result)
94
+ evidence = await self._evidence.collect(task, transition.step, role)
95
+ agent_request = (
96
+ outcome.escalation if isinstance(outcome, (ConsultAnswer, Verdict)) else None
97
+ )
98
+ await self._raiser.raise_escalation(
99
+ task,
100
+ step=transition.step,
101
+ reason=reason,
102
+ question=narrative_question if question is None else question,
103
+ attempted=attempted,
104
+ reports=evidence.reports,
105
+ facts=evidence.facts,
106
+ view_tail=evidence.view_tail,
107
+ apply_state=lambda: self._writer.persist(task, transition, outcome),
108
+ agent_request=agent_request,
109
+ )
110
+ return self._tasks.require(task.id)
@@ -0,0 +1,401 @@
1
+ """`EscalationRaiser`: builds the packet and records the escalation.
2
+
3
+ The packet keeps an agent's product alternatives separate from the engine's allowed resolution
4
+ actions. Only the latter are accepted by the resolution command contract.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from pydantic import TypeAdapter, ValidationError
17
+
18
+ from shikiru.domain.attempts import attempt_lines, summarise_attempts
19
+ from shikiru.domain.escalation import (
20
+ ESCALATION_CAUSES,
21
+ RESOLUTION_CONSEQUENCES,
22
+ EscalationOption,
23
+ options_for,
24
+ )
25
+ from shikiru.domain.escalation_evidence import NO_AGENT_PROPOSAL
26
+ from shikiru.domain.events import EventKind
27
+ from shikiru.domain.reports import (
28
+ DECISION_ESCALATE,
29
+ REPORT_JSON_INDENT,
30
+ ConsultAnswer,
31
+ EscalationRequest,
32
+ Report,
33
+ Verdict,
34
+ )
35
+ from shikiru.domain.steps import Transition
36
+ from shikiru.domain.tasks import EscalationReason
37
+ from shikiru.paths import run_dir
38
+ from shikiru.private import make_private_dir, write_private_text
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Callable, Sequence
42
+ from contextlib import AbstractContextManager
43
+
44
+ from shikiru.config.settings import Settings
45
+ from shikiru.domain.escalation import Escalation
46
+ from shikiru.domain.tasks import Step, Task
47
+ from shikiru.engine.evidence import EvidenceCollector
48
+ from shikiru.engine.titles import TaskTitles
49
+ from shikiru.engine.transitions import TransitionWriter
50
+ from shikiru.store.escalations import EscalationStore
51
+ from shikiru.store.events import EventLog
52
+ from shikiru.store.runs import RunStore
53
+
54
+ ESCALATIONS_DIR_NAME = "escalations"
55
+ PACKET_FILENAME_SUFFIX = ".md"
56
+ PACKET_TEMPORARY_SUFFIX = ".tmp"
57
+ LOST_ESCALATION_QUESTION = (
58
+ "The task was escalated but its escalation record was lost; retry or cancel."
59
+ )
60
+ LOST_ESCALATION_ATTEMPTED = (
61
+ "The engine restarted and found the task escalated with no open escalation."
62
+ )
63
+ RESTORED_PACKET_ATTEMPTED = "The packet file was missing and was rewritten from recorded state."
64
+ _LOGGER = logging.getLogger(__name__)
65
+ _REPORT_DATA = TypeAdapter(dict[str, object])
66
+ _REPORT: TypeAdapter[Report] = TypeAdapter(Report)
67
+ _REPORT_DECISION_KEY = "decision"
68
+ _VERDICT_SUMMARY_KEY = "summary"
69
+ _CONSULT_ANSWER_KEY = "answer"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class PacketContext:
74
+ """Everything `render_packet` needs to assemble one escalation packet."""
75
+
76
+ task_id: str
77
+ step: Step | None
78
+ reason: EscalationReason
79
+ timeline: Sequence[str]
80
+ attempted: str
81
+ reports: str
82
+ facts: str
83
+ view_tail: str
84
+ sessions: str
85
+ question: str
86
+ options: tuple[EscalationOption, ...]
87
+ agent_request: EscalationRequest | None = None
88
+
89
+
90
+ def _headline(context: PacketContext) -> str:
91
+ step_label = context.step.value if context.step is not None else "no step"
92
+ sentence = ESCALATION_CAUSES[context.reason]
93
+ return f"{context.task_id} · {step_label} · {context.reason.value}: {sentence}"
94
+
95
+
96
+ def _options_section(options: tuple[EscalationOption, ...]) -> str:
97
+ lines: list[str] = []
98
+ for option in options:
99
+ marker = " ★" if option.recommended else ""
100
+ lines.append(f"- `{option.label.value}`{marker}: {RESOLUTION_CONSEQUENCES[option.label]}")
101
+ return "\n".join(lines)
102
+
103
+
104
+ def _agent_request_from_reports(reports: Sequence[dict[str, str]]) -> EscalationRequest | None:
105
+ if not reports:
106
+ return None
107
+ data = _REPORT_DATA.validate_json(reports[-1]["json"])
108
+ if data.get(_REPORT_DECISION_KEY) != DECISION_ESCALATE:
109
+ return None
110
+ if _VERDICT_SUMMARY_KEY in data:
111
+ report = Verdict.model_validate(data)
112
+ elif _CONSULT_ANSWER_KEY in data:
113
+ report = ConsultAnswer.model_validate(data)
114
+ else:
115
+ return None
116
+ return report.escalation
117
+
118
+
119
+ def render_packet(context: PacketContext) -> str:
120
+ """Render `context` as the packet markdown.
121
+
122
+ Headline, timeline, what was tried, evidence, the Leader's question and typed agent
123
+ alternatives, then the engine's resolvable options. Each option set has one recommendation.
124
+ """
125
+ timeline = "\n".join(f"- {line}" for line in context.timeline) or "(no recorded events)"
126
+ agent_alternatives = (
127
+ context.agent_request.model_dump_json(indent=REPORT_JSON_INDENT)
128
+ if context.agent_request
129
+ else NO_AGENT_PROPOSAL
130
+ )
131
+ return (
132
+ f"# {_headline(context)}\n\n"
133
+ f"## Timeline\n\n{timeline}\n\n"
134
+ f"## What was tried\n\n{context.attempted}\n\n"
135
+ f"## Evidence\n\n"
136
+ f"### Reports\n\n{context.reports or '(none)'}\n\n"
137
+ f"### Facts\n\n{context.facts or '(none)'}\n\n"
138
+ f"### Recent turn signals\n\n{context.view_tail or '(none)'}\n\n"
139
+ f"### Sessions\n\n{context.sessions or '(none)'}\n\n"
140
+ f"## Question\n\n{context.question}\n\n"
141
+ f"## Agent alternatives\n\n"
142
+ f"{agent_alternatives}\n\n"
143
+ f"## Options\n\n{_options_section(context.options)}\n"
144
+ )
145
+
146
+
147
+ class EscalationRaiser:
148
+ """Builds the escalation packet and commits the escalated state with its row and event."""
149
+
150
+ def __init__(
151
+ self,
152
+ escalations: EscalationStore,
153
+ events: EventLog,
154
+ runs: RunStore,
155
+ project_root: Path,
156
+ *,
157
+ atomically: Callable[[], AbstractContextManager[object]],
158
+ titles: TaskTitles,
159
+ evidence: EvidenceCollector,
160
+ settings: Settings,
161
+ ) -> None:
162
+ """Persist through `escalations`/`events` in `atomically`, refresh titles via `titles`.
163
+
164
+ `evidence` supplies the recorded reports, facts and turn tail of a restored packet.
165
+ """
166
+ self._escalations = escalations
167
+ self._events = events
168
+ self._runs = runs
169
+ self._project_root = project_root
170
+ self._atomically = atomically
171
+ self._titles = titles
172
+ self._evidence = evidence
173
+ self._settings = settings
174
+ self._unwritable: set[Path] = set()
175
+
176
+ async def raise_escalation(
177
+ self,
178
+ task: Task,
179
+ *,
180
+ step: Step | None,
181
+ reason: EscalationReason,
182
+ question: str,
183
+ attempted: str,
184
+ reports: Sequence[dict[str, str]],
185
+ facts: str,
186
+ view_tail: str,
187
+ apply_state: Callable[[], Task],
188
+ agent_request: EscalationRequest | None = None,
189
+ ) -> Escalation:
190
+ """Write the packet, then commit `apply_state`, the escalation row and its event as one.
191
+
192
+ `apply_state` persists the task's escalated state and returns the task as stored. The
193
+ packet file is written first and removed again if the transaction fails, so an escalated
194
+ task never lacks its escalation. A packet that cannot be written does not fail the
195
+ escalation: the row commits and `restore_packet` rewrites the file once the cause is gone.
196
+ Titles refresh after the commit.
197
+ """
198
+ options = options_for(reason)
199
+ context = PacketContext(
200
+ task_id=task.id,
201
+ step=step,
202
+ reason=reason,
203
+ timeline=self._timeline(task.id),
204
+ attempted=self._attempts(task, attempted, reports, facts),
205
+ reports=self._reports_evidence(reports),
206
+ facts=facts,
207
+ view_tail=view_tail,
208
+ sessions=self._sessions_evidence(task.id),
209
+ question=question,
210
+ options=options,
211
+ agent_request=agent_request,
212
+ )
213
+ headline = _headline(context)
214
+ escalation_id = self._escalations.reserve_id()
215
+ packet_path = self._packet_path(task.id, escalation_id)
216
+ self._write_packet(task.id, packet_path, context)
217
+ try:
218
+ with self._atomically():
219
+ updated = apply_state()
220
+ escalation = self._escalations.open(
221
+ task.id,
222
+ escalation_id=escalation_id,
223
+ step=step,
224
+ reason=reason,
225
+ headline=headline,
226
+ packet_path=str(packet_path),
227
+ options=options,
228
+ )
229
+ self._events.append(
230
+ EventKind.ESCALATION,
231
+ task_id=task.id,
232
+ message=headline,
233
+ data={"escalation_id": escalation.id, "reason": reason.value},
234
+ )
235
+ except BaseException:
236
+ packet_path.unlink(missing_ok=True)
237
+ raise
238
+ await self._refresh_titles(updated)
239
+ return escalation
240
+
241
+ async def ensure_actionable(self, task: Task) -> None:
242
+ """Give an escalated `task` an open escalation with a packet on disk.
243
+
244
+ A missing packet is rewritten from durable state at its recorded path; a task without
245
+ any open escalation gets a new `engine_error` one. Neither duplicates an open escalation.
246
+ """
247
+ open_ones = [e for e in self._escalations.list_open() if e.task_id == task.id]
248
+ if not open_ones:
249
+ await self.raise_escalation(
250
+ task,
251
+ step=task.step,
252
+ reason=EscalationReason.ENGINE_ERROR,
253
+ question=LOST_ESCALATION_QUESTION,
254
+ attempted=LOST_ESCALATION_ATTEMPTED,
255
+ reports=(),
256
+ facts="",
257
+ view_tail="",
258
+ apply_state=lambda: task,
259
+ )
260
+ return
261
+ for escalation in self._without_packet(open_ones):
262
+ path = Path(escalation.packet_path)
263
+ if self._packet_directory_ready(task.id, path):
264
+ context = await self._restored_context(task, escalation)
265
+ self._write_packet(task.id, path, context)
266
+
267
+ @staticmethod
268
+ def _without_packet(escalations: Sequence[Escalation]) -> list[Escalation]:
269
+ return [e for e in escalations if not Path(e.packet_path).exists()]
270
+
271
+ async def _restored_context(self, task: Task, escalation: Escalation) -> PacketContext:
272
+ evidence = await self._evidence.collect(task, escalation.step)
273
+ return PacketContext(
274
+ task_id=task.id,
275
+ step=escalation.step,
276
+ reason=escalation.reason,
277
+ timeline=self._timeline(task.id),
278
+ attempted=self._attempts(
279
+ task, RESTORED_PACKET_ATTEMPTED, evidence.reports, evidence.facts
280
+ ),
281
+ reports=self._reports_evidence(evidence.reports),
282
+ facts=evidence.facts,
283
+ view_tail=evidence.view_tail,
284
+ sessions=self._sessions_evidence(task.id),
285
+ question=escalation.headline,
286
+ options=escalation.options,
287
+ agent_request=_agent_request_from_reports(evidence.reports),
288
+ )
289
+
290
+ def _packet_directory_ready(self, task_id: str, path: Path) -> bool:
291
+ try:
292
+ make_private_dir(path.parent)
293
+ if not os.access(path.parent, os.W_OK | os.X_OK):
294
+ message = f"{path.parent} is not writable"
295
+ raise PermissionError(message)
296
+ except OSError as error:
297
+ self._report_unwritable(task_id, path, error)
298
+ return False
299
+ return True
300
+
301
+ def _write_packet(self, task_id: str, path: Path, context: PacketContext) -> None:
302
+ try:
303
+ make_private_dir(path.parent)
304
+ temporary = path.with_name(path.name + PACKET_TEMPORARY_SUFFIX)
305
+ try:
306
+ write_private_text(temporary, render_packet(context))
307
+ temporary.replace(path)
308
+ except OSError:
309
+ with suppress(OSError):
310
+ temporary.unlink(missing_ok=True)
311
+ raise
312
+ except OSError as error:
313
+ self._report_unwritable(task_id, path, error)
314
+ else:
315
+ self._unwritable.discard(path)
316
+
317
+ def _report_unwritable(self, task_id: str, path: Path, error: OSError) -> None:
318
+ if path in self._unwritable:
319
+ return
320
+ self._unwritable.add(path)
321
+ _LOGGER.error("escalation packet %s could not be written", path, exc_info=error)
322
+ self._events.append(
323
+ EventKind.ENGINE_FAULT,
324
+ task_id=task_id,
325
+ message=f"escalation packet {path.name} could not be written: {error}",
326
+ data={"error": type(error).__name__, "recoverable": True, "packet": str(path)},
327
+ )
328
+
329
+ async def _refresh_titles(self, task: Task) -> None:
330
+ try:
331
+ await self._titles.refresh(task)
332
+ except Exception:
333
+ _LOGGER.exception("title refresh failed for %s", task.id)
334
+
335
+ def _packet_path(self, task_id: str, escalation_id: str) -> Path:
336
+ return (
337
+ run_dir(self._project_root, task_id)
338
+ / ESCALATIONS_DIR_NAME
339
+ / (escalation_id + PACKET_FILENAME_SUFFIX)
340
+ )
341
+
342
+ def _timeline(self, task_id: str) -> list[str]:
343
+ events = self._events.since(0, task_id=task_id)
344
+ return [f"{event.ts.isoformat()} {event.kind.value}: {event.message}" for event in events]
345
+
346
+ def _attempts(
347
+ self, task: Task, reason: str, reports: Sequence[dict[str, str]], facts: str
348
+ ) -> str:
349
+ items = summarise_attempts(
350
+ reason,
351
+ self._events.since(0, task_id=task.id),
352
+ self._readable_reports(reports),
353
+ task,
354
+ facts,
355
+ max_iterations=self._settings.orchestration.max_iterations,
356
+ limit=self._settings.escalation.tried_lines,
357
+ )
358
+ return attempt_lines(items)
359
+
360
+ @staticmethod
361
+ def _readable_reports(reports: Sequence[dict[str, str]]) -> dict[str, Report]:
362
+ readable: dict[str, Report] = {}
363
+ for report in reports:
364
+ with suppress(ValidationError):
365
+ readable[report["label"]] = _REPORT.validate_json(report["json"])
366
+ return readable
367
+
368
+ @staticmethod
369
+ def _reports_evidence(reports: Sequence[dict[str, str]]) -> str:
370
+ return "\n\n".join(f"**{report['label']}**\n\n{report['json']}" for report in reports)
371
+
372
+ def _sessions_evidence(self, task_id: str) -> str:
373
+ lines: list[str] = []
374
+ for run in self._runs.for_task(task_id):
375
+ resume = run.resume_command or "(not resumable)"
376
+ lines.append(f"- {run.role.value}: {run.agent} session `{run.session_id}` — {resume}")
377
+ return "\n".join(lines)
378
+
379
+
380
+ class DispatchFailureEscalator:
381
+ """Escalate a task after repeated failures while preparing its dispatch."""
382
+
383
+ def __init__(self, writer: TransitionWriter, raiser: EscalationRaiser) -> None:
384
+ """Use `writer` for the state change and `raiser` for the escalation."""
385
+ self._writer = writer
386
+ self._raiser = raiser
387
+
388
+ async def __call__(self, task: Task, error: Exception) -> None:
389
+ """Mark `task` escalated and record the dispatch error for the operator."""
390
+ transition = Transition.escalate_current(task, EscalationReason.ENGINE_ERROR)
391
+ await self._raiser.raise_escalation(
392
+ task,
393
+ step=transition.step,
394
+ reason=EscalationReason.ENGINE_ERROR,
395
+ question=str(error),
396
+ attempted="Workspace or pane setup failed after scheduler retries.",
397
+ reports=(),
398
+ facts="",
399
+ view_tail="",
400
+ apply_state=lambda: self._writer.persist(task, transition, None),
401
+ )