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 @@
1
+ """Pure domain rules: models, transitions and policies with no I/O."""
@@ -0,0 +1,130 @@
1
+ """Acceptance criteria a task request states under its acceptance-criteria heading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Final
8
+
9
+ ACCEPTANCE_CRITERIA_HEADING: Final = "Acceptance criteria"
10
+
11
+ _PHRASE: Final = re.escape(ACCEPTANCE_CRITERIA_HEADING)
12
+ _HEADING: Final = re.compile(
13
+ rf"(?:#{{1,6}}\s+)?(?:\*\*{_PHRASE}(?::\*\*|\*\*:?)|{_PHRASE}:?)\s*", re.IGNORECASE
14
+ )
15
+ _ANY_HEADING: Final = re.compile(r"\s*#{1,6}\s")
16
+ _FENCE: Final = re.compile(r"\s{0,3}(?P<marker>`{3,}|~{3,})")
17
+ _CRITERION: Final = re.compile(r"\s*(?:[-*+]|\d+[.)])\s+(?P<text>\S.*?)\s*")
18
+ _INDENTED: Final = re.compile(r"\s+\S.*")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class _Section:
23
+ start: int
24
+ end: int
25
+ criteria: tuple[str, ...]
26
+
27
+
28
+ def acceptance_criteria_from_request(request: str) -> tuple[str, ...]:
29
+ """Return the bullets under the request's acceptance-criteria heading, in order.
30
+
31
+ Fenced code is an example, not a requirement, so its lines are never read as a heading or
32
+ a criterion. The section starts after the first line that is only the heading
33
+ `ACCEPTANCE_CRITERIA_HEADING`, written with optional `#` marks, optional bold markers and an
34
+ optional trailing colon, in any letter case. An indented line continues the bullet above it.
35
+ The section ends at the first other non-blank line: prose, a heading or a code fence. A request
36
+ without that heading has no criteria.
37
+ """
38
+ section = _section(request.splitlines(keepends=True))
39
+ return () if section is None else section.criteria
40
+
41
+
42
+ def request_without_criteria_section(request: str) -> str:
43
+ """Return the request without its acceptance-criteria section, keeping all other text.
44
+
45
+ The removed section is the one `acceptance_criteria_from_request` reads: its heading line,
46
+ its bullets and their continuations, and the blank lines among them. Every other character
47
+ and line ending is kept. Blank lines left at either end of the request by the removal are
48
+ dropped. A request without criteria is returned unchanged.
49
+ """
50
+ lines = request.splitlines(keepends=True)
51
+ section = _section(lines)
52
+ if section is None:
53
+ return request
54
+ before = lines[: section.start]
55
+ after = lines[section.end :]
56
+ if not after:
57
+ before = _without_trailing_blank_lines(before)
58
+ if not before:
59
+ after = _without_leading_blank_lines(after)
60
+ return "".join([*before, *after])
61
+
62
+
63
+ def _without_trailing_blank_lines(lines: list[str]) -> list[str]:
64
+ kept = list(lines)
65
+ while kept and not kept[-1].strip():
66
+ kept.pop()
67
+ return kept
68
+
69
+
70
+ def _without_leading_blank_lines(lines: list[str]) -> list[str]:
71
+ kept = list(lines)
72
+ while kept and not kept[0].strip():
73
+ kept.pop(0)
74
+ return kept
75
+
76
+
77
+ def _section(lines: list[str]) -> _Section | None:
78
+ visible = _blank_code_blocks(lines)
79
+ start = next(
80
+ (index for index, line in enumerate(visible) if _HEADING.fullmatch(line)),
81
+ None,
82
+ )
83
+ if start is None:
84
+ return None
85
+ criteria: list[str] = []
86
+ end = len(visible)
87
+ for index in range(start + 1, len(visible)):
88
+ line = visible[index]
89
+ if not line.strip():
90
+ continue
91
+ match = _CRITERION.fullmatch(line)
92
+ if match is not None:
93
+ criteria.append(match["text"])
94
+ elif criteria and _is_continuation(line):
95
+ criteria[-1] = f"{criteria[-1]} {line.strip()}"
96
+ else:
97
+ end = index
98
+ break
99
+ if not criteria:
100
+ return None
101
+ return _Section(start=start, end=end, criteria=tuple(criteria))
102
+
103
+
104
+ def _blank_code_blocks(lines: list[str]) -> list[str]:
105
+ visible: list[str] = []
106
+ fence: str | None = None
107
+ for line in lines:
108
+ if fence is not None:
109
+ visible.append("")
110
+ if _closes_fence(line, fence):
111
+ fence = None
112
+ continue
113
+ visible.append(line.rstrip())
114
+ opening = _FENCE.match(line)
115
+ if opening is not None:
116
+ fence = opening["marker"]
117
+ return visible
118
+
119
+
120
+ def _closes_fence(line: str, fence: str) -> bool:
121
+ stripped = line.strip()
122
+ return len(stripped) >= len(fence) and stripped == fence[0] * len(stripped)
123
+
124
+
125
+ def _is_continuation(line: str) -> bool:
126
+ return (
127
+ _INDENTED.fullmatch(line) is not None
128
+ and _ANY_HEADING.match(line) is None
129
+ and _FENCE.match(line) is None
130
+ )
@@ -0,0 +1,28 @@
1
+ """Stall detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from datetime import datetime
9
+
10
+ from shikiru.config.settings import TimeoutsConfig
11
+
12
+
13
+ def seconds_until_stall(
14
+ last_activity: datetime, tool_running: bool, now: datetime, timeouts: TimeoutsConfig
15
+ ) -> float:
16
+ """Return how long past `now` the activity last seen may stay silent.
17
+
18
+ A running tool gets the longer `timeouts.tool` budget; anything else uses `timeouts.stall`.
19
+ """
20
+ threshold = timeouts.tool if tool_running else timeouts.stall
21
+ return threshold - (now - last_activity).total_seconds()
22
+
23
+
24
+ def is_stalled(
25
+ last_activity: datetime, tool_running: bool, now: datetime, timeouts: TimeoutsConfig
26
+ ) -> bool:
27
+ """Decide whether `now` is past the timeout for the kind of activity last seen."""
28
+ return seconds_until_stall(last_activity, tool_running, now, timeouts) <= 0
@@ -0,0 +1,333 @@
1
+ """The capped digest of what a task did before it escalated."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum, auto
9
+ from typing import TYPE_CHECKING
10
+
11
+ from shikiru.domain.briefing import one_line, parse_produced, reported_tests, verdict_counts
12
+ from shikiru.domain.events import EventKind
13
+ from shikiru.domain.reports import ImplementationReport, Verdict
14
+ from shikiru.domain.tasks import Step
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Mapping, Sequence
18
+
19
+ from shikiru.domain.events import Event
20
+ from shikiru.domain.reports import Report
21
+ from shikiru.domain.tasks import Task
22
+
23
+ TURN_FIELD = "turn"
24
+ ROLE_FIELD = "role"
25
+ STEP_FIELD = "step"
26
+ OUTCOME_FIELD = "outcome"
27
+ TIMES = "\N{MULTIPLICATION SIGN}"
28
+ PART_SEPARATOR = " \N{MIDDLE DOT} "
29
+ MINUS = "\N{MINUS SIGN}"
30
+ EARLIER_TEMPLATE = "+{count} earlier"
31
+ LABEL_SEPARATOR = ": "
32
+ VERDICT_SUFFIX = " verdict"
33
+ TURN_FAILED_PREFIXES = ("failed", "turn failed")
34
+ TRIAGE_TEXT = "triaged as"
35
+ PLAN_READY = "plan ready"
36
+ NO_COMMITS = "no commits"
37
+ COMPLETED = "completed"
38
+ NEEDS_DECISION = "needs_decision"
39
+ BLOCKED = "blocked"
40
+ LABEL_ROUNDS = "Rounds"
41
+ LABEL_RECOVERY = "Recovery"
42
+ LABEL_PRODUCED = "Produced"
43
+ LABEL_OPERATOR = "Operator"
44
+ FAILURE_MESSAGE_LIMIT = 80
45
+ _OUTCOME_WORDS = {
46
+ "answer": "answered",
47
+ "accept": "accepted",
48
+ "changes_requested": "changes requested",
49
+ "escalate": "escalated",
50
+ "supersede": "superseded",
51
+ NEEDS_DECISION: "needs a decision",
52
+ }
53
+ _RECOVERY_NOUNS = (
54
+ (EventKind.NUDGE, "nudge"),
55
+ (EventKind.HELPER_ACTION, "helper action"),
56
+ (EventKind.RUNG_CHANGED, "rung change"),
57
+ )
58
+ _OPERATOR_NOUNS = (
59
+ (EventKind.OPERATOR_MESSAGE, "message"),
60
+ (EventKind.TASK_RETRIED, "earlier retry"),
61
+ )
62
+
63
+
64
+ class AttemptKind(StrEnum):
65
+ """What one row of the escalation details shows."""
66
+
67
+ REASON = auto()
68
+ ROUNDS = auto()
69
+ TURN_OK = auto()
70
+ TURN_FAILED = auto()
71
+ VERDICT = auto()
72
+ RECOVERY = auto()
73
+ PRODUCED = auto()
74
+ OPERATOR = auto()
75
+ EARLIER = auto()
76
+
77
+
78
+ class AttemptTone(StrEnum):
79
+ """How a piece of a row reads: plain, a step name, success, failure or rework."""
80
+
81
+ PLAIN = auto()
82
+ STEP = auto()
83
+ SUCCESS = auto()
84
+ FAILURE = auto()
85
+ REWORK = auto()
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class AttemptPart:
90
+ """A run of row text with its tone."""
91
+
92
+ text: str
93
+ tone: AttemptTone
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class AttemptItem:
98
+ """One row of the details: a kind, its label (a section or role name) and toned parts."""
99
+
100
+ kind: AttemptKind
101
+ label: str
102
+ parts: tuple[AttemptPart, ...]
103
+
104
+ @property
105
+ def text(self) -> str:
106
+ """The row text without its label."""
107
+ return "".join(part.text for part in self.parts)
108
+
109
+ @property
110
+ def line(self) -> str:
111
+ """The `Label: text` packet line of this row."""
112
+ if self.kind in _UNLABELLED:
113
+ return self.text
114
+ label = self.label + VERDICT_SUFFIX if self.kind is AttemptKind.VERDICT else self.label
115
+ return label + LABEL_SEPARATOR + self.text
116
+
117
+
118
+ _UNLABELLED = frozenset({AttemptKind.REASON, AttemptKind.EARLIER})
119
+ _SECTION_KINDS = {
120
+ LABEL_ROUNDS: AttemptKind.ROUNDS,
121
+ LABEL_RECOVERY: AttemptKind.RECOVERY,
122
+ LABEL_PRODUCED: AttemptKind.PRODUCED,
123
+ LABEL_OPERATOR: AttemptKind.OPERATOR,
124
+ }
125
+ _STEP_NAME = "|".join(step.value for step in Step)
126
+ _EARLIER = re.compile(r"\+\d+ earlier")
127
+ _TONES: dict[AttemptKind, tuple[tuple[re.Pattern[str], AttemptTone], ...]] = {
128
+ AttemptKind.ROUNDS: (
129
+ (re.compile(rf"\b(?:{_STEP_NAME})(?= {TIMES})"), AttemptTone.STEP),
130
+ (re.compile(r"\(?iteration \d+ of \d+\)?"), AttemptTone.REWORK),
131
+ ),
132
+ AttemptKind.TURN_OK: (
133
+ (re.compile(r"\d+ commits?|\d+ tests? passed|\baccepted?\b"), AttemptTone.SUCCESS),
134
+ (re.compile(r"\d+ failed|no commits|\bblocked\b|\d+ blockers?"), AttemptTone.FAILURE),
135
+ (
136
+ re.compile(r"needs a decision|changes requested|\bescalated?\b|\bsuperseded\b"),
137
+ AttemptTone.REWORK,
138
+ ),
139
+ (re.compile(rf"^(?:{_STEP_NAME})\b"), AttemptTone.STEP),
140
+ ),
141
+ AttemptKind.TURN_FAILED: ((re.compile(r".+"), AttemptTone.FAILURE),),
142
+ AttemptKind.VERDICT: (
143
+ (re.compile(r"\baccept\b"), AttemptTone.SUCCESS),
144
+ (re.compile(r"changes requested"), AttemptTone.REWORK),
145
+ (re.compile(r"\d+ blockers?"), AttemptTone.FAILURE),
146
+ ),
147
+ AttemptKind.RECOVERY: ((re.compile(r".+"), AttemptTone.REWORK),),
148
+ AttemptKind.PRODUCED: (
149
+ (re.compile(r"\+\d+"), AttemptTone.SUCCESS),
150
+ (re.compile(rf"[-{MINUS}]\d+"), AttemptTone.FAILURE),
151
+ ),
152
+ }
153
+
154
+
155
+ def _toned(kind: AttemptKind, text: str) -> tuple[AttemptPart, ...]:
156
+ marks = sorted(
157
+ (match.start(), match.end(), tone)
158
+ for pattern, tone in _TONES.get(kind, ())
159
+ for match in pattern.finditer(text)
160
+ )
161
+ parts: list[AttemptPart] = []
162
+ cursor = 0
163
+ for start, end, tone in marks:
164
+ if start < cursor:
165
+ continue
166
+ if start > cursor:
167
+ parts.append(AttemptPart(text[cursor:start], AttemptTone.PLAIN))
168
+ parts.append(AttemptPart(text[start:end], tone))
169
+ cursor = end
170
+ if cursor < len(text):
171
+ parts.append(AttemptPart(text[cursor:], AttemptTone.PLAIN))
172
+ return tuple(parts)
173
+
174
+
175
+ def _item(kind: AttemptKind, label: str, text: str) -> AttemptItem:
176
+ return AttemptItem(kind, label, _toned(kind, text))
177
+
178
+
179
+ def parse_attempt_lines(content: str) -> tuple[AttemptItem, ...]:
180
+ """Rebuild the details rows from a packet's `What was tried` text.
181
+
182
+ The first line is the reason; a canned one-sentence packet is a single reason row.
183
+ """
184
+ lines = [line.strip() for line in content.splitlines() if line.strip()]
185
+ if not lines:
186
+ return ()
187
+ return (_item(AttemptKind.REASON, "", lines[0]), *map(_parse_line, lines[1:]))
188
+
189
+
190
+ def _parse_line(line: str) -> AttemptItem:
191
+ if _EARLIER.fullmatch(line):
192
+ return _item(AttemptKind.EARLIER, "", line)
193
+ label, separator, text = line.partition(LABEL_SEPARATOR)
194
+ if not separator:
195
+ return _item(AttemptKind.REASON, "", line)
196
+ if label in _SECTION_KINDS:
197
+ return _item(_SECTION_KINDS[label], label, text)
198
+ if label.endswith(VERDICT_SUFFIX):
199
+ return _item(AttemptKind.VERDICT, label.removesuffix(VERDICT_SUFFIX), text)
200
+ failed = text.startswith(TURN_FAILED_PREFIXES)
201
+ return _item(AttemptKind.TURN_FAILED if failed else AttemptKind.TURN_OK, label, text)
202
+
203
+
204
+ def _count(count: int, noun: str) -> str:
205
+ return f"{count} {noun}{'' if count == 1 else 's'}"
206
+
207
+
208
+ def _counted(counts: Counter[EventKind], nouns: tuple[tuple[EventKind, str], ...]) -> str:
209
+ return ", ".join(_count(counts[kind], noun) for kind, noun in nouns if counts[kind])
210
+
211
+
212
+ def _rounds(events: Sequence[Event], task: Task, max_iterations: int) -> AttemptItem | None:
213
+ steps = Counter(
214
+ str(event.data.get(STEP_FIELD)) for event in events if event.kind is EventKind.STEP_STARTED
215
+ )
216
+ if not steps:
217
+ return None
218
+ text = PART_SEPARATOR.join(f"{step} {TIMES}{count}" for step, count in steps.items())
219
+ if task.iteration > 0:
220
+ text += f" (iteration {task.iteration} of {max_iterations})"
221
+ return _item(AttemptKind.ROUNDS, LABEL_ROUNDS, text)
222
+
223
+
224
+ def _implementation_phrase(outcome: str, report: ImplementationReport) -> str:
225
+ tests = reported_tests(report.tests)
226
+ if outcome == BLOCKED:
227
+ return BLOCKED
228
+ commits = [_count(len(report.commits), "commit")] if report.commits else []
229
+ parts = list(commits)
230
+ if tests.passed:
231
+ parts.append(f"{_count(tests.passed, 'test')} passed")
232
+ if tests.failed:
233
+ parts.append(f"{tests.failed} failed")
234
+ if outcome == NEEDS_DECISION:
235
+ return ", ".join([_OUTCOME_WORDS[NEEDS_DECISION], *commits])
236
+ return ", ".join(parts) or NO_COMMITS
237
+
238
+
239
+ def _verdict_phrase(report: Verdict) -> str:
240
+ counts = verdict_counts(report)
241
+ text = _OUTCOME_WORDS.get(counts.decision, counts.decision)
242
+ if counts.blocker:
243
+ text += f" ({_count(counts.blocker, 'blocker')})"
244
+ return text
245
+
246
+
247
+ def _outcome_phrase(step: object, outcome: str, report: Report | None) -> str:
248
+ if isinstance(report, ImplementationReport):
249
+ return _implementation_phrase(outcome, report)
250
+ if isinstance(report, Verdict):
251
+ return _verdict_phrase(report)
252
+ if step == Step.TRIAGE:
253
+ return f"{TRIAGE_TEXT} {outcome}"
254
+ if step == Step.PLAN and outcome == COMPLETED:
255
+ return PLAN_READY
256
+ return _OUTCOME_WORDS.get(outcome, outcome)
257
+
258
+
259
+ def _turn_item(event: Event, reports: Mapping[str, Report]) -> AttemptItem:
260
+ role = str(event.data.get(ROLE_FIELD, "agent")).capitalize()
261
+ if event.kind is EventKind.TURN_FAILED:
262
+ message = one_line(event.message, FAILURE_MESSAGE_LIMIT) or "no message"
263
+ return _item(AttemptKind.TURN_FAILED, role, f"{TURN_FAILED_PREFIXES[0]} ({message})")
264
+ step = event.data.get(STEP_FIELD)
265
+ report = reports.get(str(event.data.get(TURN_FIELD)))
266
+ phrase = _outcome_phrase(step, str(event.data.get(OUTCOME_FIELD)), report)
267
+ return _item(AttemptKind.TURN_OK, role, f"{step}{PART_SEPARATOR}{phrase}" if step else phrase)
268
+
269
+
270
+ def _turn_items(
271
+ events: Sequence[Event], reports: Mapping[str, Report], room: int
272
+ ) -> list[AttemptItem]:
273
+ items = [
274
+ _turn_item(event, reports)
275
+ for event in events
276
+ if event.kind in (EventKind.TURN_COMPLETED, EventKind.TURN_FAILED)
277
+ ]
278
+ if len(items) <= room:
279
+ return items
280
+ kept = max(room - 1, 0)
281
+ earlier = _item(AttemptKind.EARLIER, "", EARLIER_TEMPLATE.format(count=len(items) - kept))
282
+ return [earlier, *items[len(items) - kept :]][:room]
283
+
284
+
285
+ def _produced(facts: str) -> AttemptItem | None:
286
+ produced = parse_produced(facts)
287
+ if produced is None:
288
+ return None
289
+ text = (
290
+ f"{_count(produced.commits, 'commit')}, {_count(produced.files, 'file')}, "
291
+ f"+{produced.insertions} {MINUS}{produced.deletions}"
292
+ )
293
+ return _item(AttemptKind.PRODUCED, LABEL_PRODUCED, text)
294
+
295
+
296
+ def _section(kind: AttemptKind, label: str, text: str) -> AttemptItem | None:
297
+ return _item(kind, label, text) if text else None
298
+
299
+
300
+ def attempt_lines(items: Sequence[AttemptItem]) -> str:
301
+ """Return the packet text of `items`, one `Label: text` line per row."""
302
+ return "\n".join(item.line for item in items)
303
+
304
+
305
+ def summarise_attempts(
306
+ reason: str,
307
+ events: Sequence[Event],
308
+ reports: Mapping[str, Report],
309
+ task: Task,
310
+ facts: str,
311
+ *,
312
+ max_iterations: int,
313
+ limit: int,
314
+ ) -> tuple[AttemptItem, ...]:
315
+ """Return at most `limit` chronological rows of what `task` did, `reason` first.
316
+
317
+ Rounds, the latest turns with their outcome, recovery, produced numbers and Operator input
318
+ appear only when recorded; the oldest turns collapse into a `+N earlier` row.
319
+ """
320
+ counts = Counter(event.kind for event in events)
321
+ head = [
322
+ _item(AttemptKind.REASON, "", reason),
323
+ _rounds(events, task, max_iterations),
324
+ ]
325
+ tail = [
326
+ _section(AttemptKind.RECOVERY, LABEL_RECOVERY, _counted(counts, _RECOVERY_NOUNS)),
327
+ _produced(facts),
328
+ _section(AttemptKind.OPERATOR, LABEL_OPERATOR, _counted(counts, _OPERATOR_NOUNS)),
329
+ ]
330
+ leading = [item for item in head if item is not None]
331
+ trailing = [item for item in tail if item is not None]
332
+ room = max(limit - len(leading) - len(trailing), 0)
333
+ return (*leading, *_turn_items(events, reports, room), *trailing)[:limit]