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,186 @@
1
+ """Read structured decision details from Shikiru's escalation packet format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from shikiru.domain.commands import ResolutionKind
10
+ from shikiru.domain.reports import EscalationRequest
11
+
12
+ _QUESTION = "Question"
13
+ _AGENT_ALTERNATIVES = "Agent alternatives"
14
+ _RESOLUTIONS = "Options"
15
+ _TIMELINE = "Timeline"
16
+ _WHAT_WAS_TRIED = "What was tried"
17
+ _EVIDENCE = "Evidence"
18
+ _REPORTS = "Reports"
19
+ _FACTS = "Facts"
20
+ _RECENT_SIGNALS = "Recent turn signals"
21
+ _SESSIONS = "Sessions"
22
+ _NONE_RECORDED = "(none recorded)"
23
+ NO_AGENT_PROPOSAL = "No agent proposal was recorded. Engine recovery actions are listed separately."
24
+ _TOP_LEVEL_PREFIX = "## "
25
+ _SUBSECTION_PREFIX = "### "
26
+ _OPTION_PREFIX = "- `"
27
+ _OPTION_KIND_DELIMITER = "`"
28
+ _RECOMMENDED_MARKER = " ★"
29
+ _CONSEQUENCE_PREFIX = ": "
30
+ _EMPTY_PACKET = "escalation packet is empty"
31
+ _SECTION_MISSING = "escalation packet has no {heading!r} section"
32
+ _SECTION_AMBIGUOUS = "escalation packet has ambiguous {heading!r} section boundaries"
33
+ _SECTION_EMPTY = "escalation packet has an empty {heading!r} section"
34
+ _ALTERNATIVES_INVALID = "escalation packet has malformed 'Agent alternatives' data"
35
+ _OPTIONS_INVALID = "escalation packet has malformed 'Options' entry"
36
+ _OPTIONS_EMPTY = "escalation packet has no options"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PacketResolution:
41
+ """One engine-allowed resolution and its recorded consequences."""
42
+
43
+ kind: ResolutionKind
44
+ consequences: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class EscalationPacket:
49
+ """Structured information recognized in the built-in escalation packet format."""
50
+
51
+ question: str | None
52
+ agent_request: EscalationRequest | None
53
+ resolutions: tuple[PacketResolution, ...]
54
+ evidence_sections: tuple[tuple[str, str], ...]
55
+ errors: tuple[str, ...]
56
+
57
+
58
+ def parse_escalation_packet(packet: str) -> EscalationPacket:
59
+ """Read the decision and evidence sections used by the built-in packet renderer."""
60
+ if not packet:
61
+ return EscalationPacket(None, None, (), (), (_EMPTY_PACKET,))
62
+
63
+ errors: list[str] = []
64
+ question_end = (
65
+ _AGENT_ALTERNATIVES if _section_exists(packet, _AGENT_ALTERNATIVES) else _RESOLUTIONS
66
+ )
67
+ question = _section(packet, _QUESTION, question_end, errors)
68
+ if question is None:
69
+ errors.append(_SECTION_MISSING.format(heading=_QUESTION))
70
+ elif not question:
71
+ errors.append(_SECTION_EMPTY.format(heading=_QUESTION))
72
+ question = None
73
+
74
+ agent_request = _agent_request(packet, errors)
75
+ resolutions = _resolutions(packet, errors)
76
+ evidence_sections = tuple(
77
+ (label, content)
78
+ for heading, following, label, subsection, following_subsection in (
79
+ (_TIMELINE, _WHAT_WAS_TRIED, _TIMELINE, False, False),
80
+ (_WHAT_WAS_TRIED, _EVIDENCE, _WHAT_WAS_TRIED, False, False),
81
+ (_REPORTS, _FACTS, _REPORTS, True, True),
82
+ (_FACTS, _RECENT_SIGNALS, _FACTS, True, True),
83
+ (_RECENT_SIGNALS, _SESSIONS, _RECENT_SIGNALS, True, True),
84
+ (_SESSIONS, _QUESTION, _SESSIONS, True, False),
85
+ )
86
+ if (
87
+ content := _section(
88
+ packet,
89
+ heading,
90
+ following,
91
+ errors,
92
+ subsection=subsection,
93
+ following_subsection=following_subsection,
94
+ )
95
+ )
96
+ is not None
97
+ )
98
+ return EscalationPacket(
99
+ question,
100
+ agent_request,
101
+ resolutions,
102
+ evidence_sections,
103
+ tuple(errors),
104
+ )
105
+
106
+
107
+ def _agent_request(packet: str, errors: list[str]) -> EscalationRequest | None:
108
+ if not _section_exists(packet, _AGENT_ALTERNATIVES):
109
+ return None
110
+ section = _section(packet, _AGENT_ALTERNATIVES, _RESOLUTIONS, errors)
111
+ if section in {_NONE_RECORDED, NO_AGENT_PROPOSAL}:
112
+ return None
113
+ if section is None:
114
+ return None
115
+ try:
116
+ return EscalationRequest.model_validate_json(section)
117
+ except ValidationError:
118
+ errors.append(_ALTERNATIVES_INVALID)
119
+ return None
120
+
121
+
122
+ def _resolutions(packet: str, errors: list[str]) -> tuple[PacketResolution, ...]:
123
+ section = _section(packet, _RESOLUTIONS, None, errors)
124
+ if section is None:
125
+ return ()
126
+ resolutions: list[PacketResolution] = []
127
+ for line in section.splitlines():
128
+ if not line.startswith(_OPTION_PREFIX):
129
+ continue
130
+ entry = line.removeprefix(_OPTION_PREFIX)
131
+ kind_text, separator, details = entry.partition(_OPTION_KIND_DELIMITER)
132
+ if not separator:
133
+ errors.append(_OPTIONS_INVALID)
134
+ continue
135
+ details = details.removeprefix(_RECOMMENDED_MARKER)
136
+ if not details.startswith(_CONSEQUENCE_PREFIX):
137
+ errors.append(_OPTIONS_INVALID)
138
+ continue
139
+ try:
140
+ kind = ResolutionKind(kind_text)
141
+ except ValueError:
142
+ errors.append(_OPTIONS_INVALID)
143
+ continue
144
+ resolutions.append(PacketResolution(kind, details.removeprefix(_CONSEQUENCE_PREFIX)))
145
+ if not resolutions:
146
+ errors.append(_OPTIONS_EMPTY)
147
+ return tuple(resolutions)
148
+
149
+
150
+ def _section_exists(packet: str, heading: str) -> bool:
151
+ return f"{_TOP_LEVEL_PREFIX}{heading}" in packet.splitlines()
152
+
153
+
154
+ def _section(
155
+ packet: str,
156
+ heading: str,
157
+ following: str | None,
158
+ errors: list[str],
159
+ *,
160
+ subsection: bool = False,
161
+ following_subsection: bool | None = None,
162
+ ) -> str | None:
163
+ lines = packet.splitlines()
164
+ prefix = _SUBSECTION_PREFIX if subsection else _TOP_LEVEL_PREFIX
165
+ marker = f"{prefix}{heading}"
166
+ matches = [index for index, line in enumerate(lines) if line == marker]
167
+ if not matches:
168
+ errors.append(_SECTION_MISSING.format(heading=heading))
169
+ return None
170
+ if len(matches) != 1:
171
+ errors.append(_SECTION_AMBIGUOUS.format(heading=heading))
172
+ return None
173
+ start = matches[0] + 1
174
+ if following is None:
175
+ end = len(lines)
176
+ else:
177
+ end_prefix = _SUBSECTION_PREFIX if following_subsection is True else _TOP_LEVEL_PREFIX
178
+ if following_subsection is None:
179
+ end_prefix = prefix
180
+ following_marker = f"{end_prefix}{following}"
181
+ following_matches = [index for index, line in enumerate(lines) if line == following_marker]
182
+ if len(following_matches) != 1 or following_matches[0] < start:
183
+ errors.append(_SECTION_AMBIGUOUS.format(heading=heading))
184
+ return None
185
+ end = following_matches[0]
186
+ return "\n".join(lines[start:end]).strip()
@@ -0,0 +1,144 @@
1
+ """The escalation resolution panel model: who, why, ordered choices and the preselection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import StrEnum, auto
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.domain.attempts import AttemptItem, parse_attempt_lines
10
+ from shikiru.domain.commands import ResolutionKind
11
+ from shikiru.domain.escalation import (
12
+ ESCALATION_CAUSES,
13
+ RESOLUTION_CONSEQUENCES,
14
+ EscalationOption,
15
+ escalation_reason_label,
16
+ )
17
+ from shikiru.domain.tasks import DomainModel, EscalationReason
18
+
19
+ if TYPE_CHECKING:
20
+ from shikiru.domain.escalation import Escalation
21
+ from shikiru.domain.escalation_evidence import EscalationPacket
22
+
23
+ _WHAT_WAS_TRIED = "What was tried"
24
+ _ENGINE_ORDER = (ResolutionKind.ACCEPT, ResolutionKind.RETRY, ResolutionKind.CANCEL)
25
+
26
+
27
+ class ChoiceKind(StrEnum):
28
+ """What sending a choice does."""
29
+
30
+ ANSWER_OPTION = auto()
31
+ OWN_ANSWER = auto()
32
+ ACCEPT = auto()
33
+ RETRY = auto()
34
+ CANCEL = auto()
35
+
36
+
37
+ _CHOICE_BY_RESOLUTION = {
38
+ ResolutionKind.ANSWER: ChoiceKind.OWN_ANSWER,
39
+ ResolutionKind.ACCEPT: ChoiceKind.ACCEPT,
40
+ ResolutionKind.RETRY: ChoiceKind.RETRY,
41
+ ResolutionKind.CANCEL: ChoiceKind.CANCEL,
42
+ }
43
+
44
+
45
+ class PanelChoice(DomainModel):
46
+ """One selectable row; `label` is set only for a Leader alternative."""
47
+
48
+ kind: ChoiceKind
49
+ consequence: str
50
+ recommended: bool = False
51
+ label: str | None = None
52
+ needs_input: bool = False
53
+
54
+
55
+ class ResolutionPanel(DomainModel):
56
+ """Everything the panel shows for one escalation; age is derived from `created_at`."""
57
+
58
+ escalation_id: str
59
+ task_id: str
60
+ task_title: str
61
+ reason: EscalationReason
62
+ reason_label: str
63
+ cause: str | None
64
+ question: str | None
65
+ created_at: datetime
66
+ choices: tuple[PanelChoice, ...]
67
+ preselected: int
68
+ details: tuple[AttemptItem, ...]
69
+ disabled_reason: str | None
70
+
71
+
72
+ def build_resolution_panel(
73
+ escalation: Escalation,
74
+ packet: EscalationPacket | None,
75
+ task_title: str,
76
+ disabled_reason: str | None,
77
+ ) -> ResolutionPanel:
78
+ """Build the panel for `escalation`; `packet` is `None` when it is missing or unreadable."""
79
+ answerable = any(option.label is ResolutionKind.ANSWER for option in escalation.options)
80
+ request = packet.agent_request if packet is not None and answerable else None
81
+ choices = (
82
+ *(
83
+ PanelChoice(
84
+ kind=ChoiceKind.ANSWER_OPTION,
85
+ consequence=option.consequences,
86
+ recommended=option.recommended,
87
+ label=option.label,
88
+ needs_input=option.needs_input,
89
+ )
90
+ for option in (request.options if request else ())
91
+ ),
92
+ *_engine_choices(escalation.options),
93
+ )
94
+ return ResolutionPanel(
95
+ escalation_id=escalation.id,
96
+ task_id=escalation.task_id,
97
+ task_title=task_title,
98
+ reason=escalation.reason,
99
+ reason_label=escalation_reason_label(escalation.reason),
100
+ cause=None
101
+ if escalation.reason is EscalationReason.DECISION_REQUIRED
102
+ else ESCALATION_CAUSES[escalation.reason],
103
+ question=_question(packet, request.question if request else None) if answerable else None,
104
+ created_at=escalation.created_at,
105
+ choices=choices,
106
+ preselected=_preselected(choices),
107
+ details=_details(packet),
108
+ disabled_reason=disabled_reason,
109
+ )
110
+
111
+
112
+ def _engine_choices(options: tuple[EscalationOption, ...]) -> tuple[PanelChoice, ...]:
113
+ by_kind = {option.label: option for option in options}
114
+ ordered = (ResolutionKind.ANSWER, *_ENGINE_ORDER)
115
+ return tuple(
116
+ PanelChoice(
117
+ kind=_CHOICE_BY_RESOLUTION[kind],
118
+ consequence=RESOLUTION_CONSEQUENCES[kind],
119
+ recommended=by_kind[kind].recommended,
120
+ )
121
+ for kind in ordered
122
+ if kind in by_kind
123
+ )
124
+
125
+
126
+ def _question(packet: EscalationPacket | None, request_question: str | None) -> str | None:
127
+ return request_question or (packet.question if packet is not None else None)
128
+
129
+
130
+ def _preselected(choices: tuple[PanelChoice, ...]) -> int:
131
+ for wanted in (ChoiceKind.ANSWER_OPTION, None):
132
+ for index, choice in enumerate(choices):
133
+ if choice.recommended and (wanted is None or choice.kind is wanted):
134
+ return index
135
+ return 0
136
+
137
+
138
+ def _details(packet: EscalationPacket | None) -> tuple[AttemptItem, ...]:
139
+ if packet is None:
140
+ return ()
141
+ for label, content in packet.evidence_sections:
142
+ if label == _WHAT_WAS_TRIED:
143
+ return parse_attempt_lines(content)
144
+ return ()
@@ -0,0 +1,71 @@
1
+ """`EventKind`, the closed set of event kinds written to `events`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import StrEnum, auto
7
+ from typing import Any
8
+
9
+ from pydantic import Field
10
+
11
+ from shikiru.domain.tasks import DomainModel
12
+
13
+ STOP_REASON_KEY = "reason"
14
+
15
+
16
+ class EventKind(StrEnum):
17
+ """Every event kind the engine can emit."""
18
+
19
+ TASK_QUEUED = auto()
20
+ TASK_STARTED = auto()
21
+ DISPATCH_FAILED = auto()
22
+ STEP_STARTED = auto()
23
+ TURN_STARTED = auto()
24
+ TURN_COMPLETED = auto()
25
+ TURN_FAILED = auto()
26
+ TURN_STOPPED = auto()
27
+ NUDGE = auto()
28
+ HELPER_ACTION = auto()
29
+ RUNG_CHANGED = auto()
30
+ COMPACTED = auto()
31
+ QUOTA_BLOCKED = auto()
32
+ QUOTA_CLEARED = auto()
33
+ VERDICT = auto()
34
+ SCOPE_CHANGE = auto()
35
+ HOLD_SET = auto()
36
+ HOLD_CLEARED = auto()
37
+ TAKEN_OVER = auto()
38
+ HANDED_BACK = auto()
39
+ TAKEOVER_LOST = auto()
40
+ OPERATOR_MESSAGE = auto()
41
+ ESCALATION = auto()
42
+ ESCALATION_RESOLVED = auto()
43
+ INTEGRATION_STARTED = auto()
44
+ INTEGRATION_APPLYING = auto()
45
+ INTEGRATION_WAITING = auto()
46
+ PR_OPENED = auto()
47
+ PR_WITHDRAWN = auto()
48
+ FEEDBACK_STARTED = auto()
49
+ FEEDBACK_REBASE_STARTED = auto()
50
+ FEEDBACK_PREPARED = auto()
51
+ FEEDBACK_PUBLISHED = auto()
52
+ FEEDBACK_HANDLED = auto()
53
+ TASK_DONE = auto()
54
+ TASK_RETRIED = auto()
55
+ TASK_CANCELLED = auto()
56
+ PANE_RESTORED = auto()
57
+ ENGINE_STARTED = auto()
58
+ ENGINE_STOPPED = auto()
59
+ ENGINE_FAULT = auto()
60
+ ENGINE_RECOVERED = auto()
61
+
62
+
63
+ class Event(DomainModel):
64
+ """One recorded event (mirrors the `events` table)."""
65
+
66
+ id: int
67
+ ts: datetime
68
+ task_id: str | None = None
69
+ kind: EventKind
70
+ message: str
71
+ data: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,89 @@
1
+ """Versioned Markdown and JSON rendering for a task evidence export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Mapping
10
+
11
+ EVIDENCE_EXPORT_FORMAT = "shikiru.task-evidence"
12
+ EVIDENCE_EXPORT_VERSION = 1
13
+ JSON_INDENT = 2
14
+ MINIMUM_MARKDOWN_FENCE = 3
15
+ MARKDOWN_NEWLINE = "\n"
16
+ MARKDOWN_FENCE_CHARACTER = "`"
17
+
18
+
19
+ def render_evidence_json(document: Mapping[str, object]) -> str:
20
+ """Render a JSON-safe task evidence document with stable, readable indentation."""
21
+ return f"{json.dumps(document, ensure_ascii=False, indent=JSON_INDENT, allow_nan=False)}\n"
22
+
23
+
24
+ def render_evidence_markdown(document: Mapping[str, object]) -> str:
25
+ """Render an evidence document while keeping recorded text inside safe code fences."""
26
+ lines = [
27
+ "# Shikiru task evidence export",
28
+ "",
29
+ "## Export metadata",
30
+ _code_block(document.get("metadata")),
31
+ "",
32
+ "## Request",
33
+ _code_block(document.get("request")),
34
+ "",
35
+ "## Recorded outcome",
36
+ _code_block(document.get("outcome")),
37
+ "",
38
+ "## Task and rung metadata",
39
+ _code_block(document.get("task")),
40
+ "",
41
+ "## Relationships",
42
+ _code_block(document.get("relationships")),
43
+ "",
44
+ "## Validated reports and verdicts",
45
+ _code_block(document.get("reports")),
46
+ "",
47
+ "## Recorded git facts",
48
+ _code_block(document.get("facts")),
49
+ "",
50
+ "## Recorded timeline",
51
+ _code_block(document.get("timeline")),
52
+ "",
53
+ "## Recorded sessions",
54
+ _code_block(document.get("sessions")),
55
+ "",
56
+ "## Task usage summary",
57
+ _code_block(document.get("usage")),
58
+ "",
59
+ "## Unavailable sections",
60
+ _code_block(document.get("unavailable_sections")),
61
+ "",
62
+ (
63
+ "This export contains retained Shikiru evidence. It is not a complete agent transcript "
64
+ "and does not recreate a removed worktree."
65
+ ),
66
+ "",
67
+ ]
68
+ return MARKDOWN_NEWLINE.join(lines)
69
+
70
+
71
+ def _code_block(value: object) -> str:
72
+ content = json.dumps(value, ensure_ascii=False, indent=JSON_INDENT, allow_nan=False)
73
+ fence = MARKDOWN_FENCE_CHARACTER * max(
74
+ MINIMUM_MARKDOWN_FENCE,
75
+ _longest_backtick_run(content) + 1,
76
+ )
77
+ return f"{fence}json\n{content}\n{fence}"
78
+
79
+
80
+ def _longest_backtick_run(value: str) -> int:
81
+ longest = 0
82
+ current = 0
83
+ for character in value:
84
+ if character == MARKDOWN_FENCE_CHARACTER:
85
+ current += 1
86
+ longest = max(longest, current)
87
+ else:
88
+ current = 0
89
+ return longest
@@ -0,0 +1,86 @@
1
+ """Open engine fault streaks derived from `engine_fault` and `engine_recovered` events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.domain.events import Event, EventKind
9
+ from shikiru.domain.tasks import DomainModel
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterable
13
+
14
+ SCOPE_KEY = "scope"
15
+ ERROR_KEY = "error"
16
+ RECOVERABLE_KEY = "recoverable"
17
+ REPEAT_KEY = "repeat"
18
+ DETAIL_KEY = "detail"
19
+ COMMAND_KEY = "command"
20
+ EXIT_CODE_KEY = "exit_code"
21
+ STDERR_KEY = "stderr"
22
+ TRACEBACK_KEY = "traceback"
23
+ _BACKOFF_FACTOR = 2
24
+
25
+
26
+ class FaultStreak(DomainModel):
27
+ """A fault that is still failing: when it began, was last recorded and how often it failed."""
28
+
29
+ scope: str
30
+ task_id: str | None
31
+ error: str
32
+ recoverable: bool
33
+ first_seen: datetime
34
+ last_seen: datetime
35
+ repeat: int
36
+ event_id: int
37
+ message: str
38
+ detail: str | None = None
39
+ command: tuple[str, ...] | None = None
40
+ exit_code: int | None = None
41
+ stderr: str | None = None
42
+ traceback: str | None = None
43
+
44
+
45
+ def open_fault_streaks(events: Iterable[Event]) -> list[FaultStreak]:
46
+ """Fold chronological fault and recovery `events` into the streaks that were never recovered.
47
+
48
+ A fault without a `scope` is a one-off record, not a streak, and is ignored. A streak keeps
49
+ the evidence of its latest record; evidence that record did not carry stays `None`.
50
+ """
51
+ open_streaks: dict[tuple[str, str | None], FaultStreak] = {}
52
+ for event in events:
53
+ scope = event.data.get(SCOPE_KEY)
54
+ if scope is None:
55
+ continue
56
+ key = (scope, event.task_id)
57
+ if event.kind is EventKind.ENGINE_RECOVERED:
58
+ open_streaks.pop(key, None)
59
+ elif event.kind is EventKind.ENGINE_FAULT:
60
+ previous = open_streaks.get(key)
61
+ open_streaks[key] = FaultStreak(
62
+ scope=scope,
63
+ task_id=event.task_id,
64
+ error=event.data[ERROR_KEY],
65
+ recoverable=event.data[RECOVERABLE_KEY],
66
+ first_seen=previous.first_seen if previous else event.ts,
67
+ last_seen=event.ts,
68
+ repeat=(previous.repeat if previous else 0) + event.data[REPEAT_KEY],
69
+ event_id=event.id,
70
+ message=event.message,
71
+ detail=event.data.get(DETAIL_KEY),
72
+ command=_command(event.data.get(COMMAND_KEY)),
73
+ exit_code=event.data.get(EXIT_CODE_KEY),
74
+ stderr=event.data.get(STDERR_KEY),
75
+ traceback=event.data.get(TRACEBACK_KEY),
76
+ )
77
+ return list(open_streaks.values())
78
+
79
+
80
+ def _command(recorded: list[str] | None) -> tuple[str, ...] | None:
81
+ return None if recorded is None else tuple(recorded)
82
+
83
+
84
+ def next_backoff(previous: float, *, initial: float, maximum: float) -> float:
85
+ """Return the retry delay after `previous` (0 before the first failure): doubled, capped."""
86
+ return initial if previous <= 0 else min(previous * _BACKOFF_FACTOR, maximum)