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,17 @@
1
+ """Reusable process and stream tools for agent plugins."""
2
+
3
+ from shikiru.agents.sdk.turn import (
4
+ StreamingTurn,
5
+ TurnParser,
6
+ TurnProcess,
7
+ TurnProcessResult,
8
+ TurnProcessSequence,
9
+ )
10
+
11
+ __all__ = [
12
+ "StreamingTurn",
13
+ "TurnParser",
14
+ "TurnProcess",
15
+ "TurnProcessResult",
16
+ "TurnProcessSequence",
17
+ ]
@@ -0,0 +1,137 @@
1
+ """`ResetTimeParser`: turns an agent's reset-time strings into aware `datetime`s."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import TYPE_CHECKING
8
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ from shikiru.clock import Clock
14
+
15
+ _EPOCH_SECONDS_PATTERN = re.compile(r"^\d+(\.\d+)?$")
16
+ _CLOCK_TIME_PATTERN = re.compile(
17
+ r"resets\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<meridiem>am|pm)\s*\((?P<zone>[^)]+)\)",
18
+ re.IGNORECASE,
19
+ )
20
+ _RELATIVE_DURATION_COMPONENT = r"(?P<amount>\d+)\s*(?P<unit>second|minute|hour|day)s?"
21
+ _RELATIVE_DURATION_PATTERN = re.compile(
22
+ rf"in\s+(?P<components>(?:{_RELATIVE_DURATION_COMPONENT}\s*)+)", re.IGNORECASE
23
+ )
24
+ _RELATIVE_DURATION_COMPONENT_PATTERN = re.compile(_RELATIVE_DURATION_COMPONENT, re.IGNORECASE)
25
+ _DATE_TEXT_PATTERN = re.compile(
26
+ r"(?P<month>[A-Za-z]{3})[a-z]*\.?\s+(?P<day>\d{1,2})(?:st|nd|rd|th)?,?\s+(?P<year>\d{4})"
27
+ r"(?:\s+at)?\s+(?P<hour>\d{1,2}):(?P<minute>\d{2})\s*(?P<meridiem>am|pm)"
28
+ r"(?:\s*\((?P<zone>[^)]+)\)|\s+(?P<utc>UTC|GMT)\b)?",
29
+ re.IGNORECASE,
30
+ )
31
+ _MONTHS = ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec")
32
+ _PM = "pm"
33
+ _SECONDS_PER_UNIT = {"second": 1, "minute": 60, "hour": 3600, "day": 86400}
34
+ _HOURS_PER_MERIDIEM = 12
35
+
36
+
37
+ class ResetTimeParser:
38
+ """Parses a `resets ...`, ISO, epoch-seconds or relative-duration string into a `datetime`."""
39
+
40
+ def __init__(
41
+ self, clock: Clock, *, resolve_local: Callable[[datetime], datetime] | None = None
42
+ ) -> None:
43
+ """Anchor relative and clock-time parsing on `clock.now()`, never the wall clock.
44
+
45
+ `resolve_local` turns a naive wall-clock time into the aware instant it names in the
46
+ zone the agent printed it in. Without it a date text that names no zone stays unknown.
47
+ """
48
+ self._clock = clock
49
+ self._resolve_local = resolve_local
50
+
51
+ def parse(self, text: str) -> datetime | None:
52
+ """Return the aware `datetime` `text` names, or `None` if no known format matches."""
53
+ stripped = text.strip()
54
+ return (
55
+ self._parse_epoch(stripped)
56
+ or self._parse_iso(stripped)
57
+ or self._parse_relative(stripped)
58
+ or self._parse_clock_time(stripped)
59
+ or self._parse_date_text(stripped)
60
+ )
61
+
62
+ def _parse_epoch(self, text: str) -> datetime | None:
63
+ if not _EPOCH_SECONDS_PATTERN.match(text):
64
+ return None
65
+ return datetime.fromtimestamp(float(text), tz=UTC)
66
+
67
+ def _parse_iso(self, text: str) -> datetime | None:
68
+ try:
69
+ parsed = datetime.fromisoformat(text)
70
+ except ValueError:
71
+ return None
72
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
73
+
74
+ def _parse_relative(self, text: str) -> datetime | None:
75
+ match = _RELATIVE_DURATION_PATTERN.search(text)
76
+ if match is None:
77
+ return None
78
+ seconds = sum(
79
+ int(component.group("amount")) * _SECONDS_PER_UNIT[component.group("unit").lower()]
80
+ for component in _RELATIVE_DURATION_COMPONENT_PATTERN.finditer(
81
+ match.group("components")
82
+ )
83
+ )
84
+ return self._clock.now() + timedelta(seconds=seconds)
85
+
86
+ def _parse_clock_time(self, text: str) -> datetime | None:
87
+ match = _CLOCK_TIME_PATTERN.search(text)
88
+ if match is None:
89
+ return None
90
+ try:
91
+ zone = ZoneInfo(match.group("zone"))
92
+ except ZoneInfoNotFoundError, ValueError:
93
+ return None
94
+ hour = int(match.group("hour")) % _HOURS_PER_MERIDIEM
95
+ if match.group("meridiem").lower() == _PM:
96
+ hour += _HOURS_PER_MERIDIEM
97
+ minute = int(match.group("minute") or 0)
98
+ now_in_zone = self._clock.now().astimezone(zone)
99
+ candidate = now_in_zone.replace(hour=hour, minute=minute, second=0, microsecond=0)
100
+ if candidate <= now_in_zone:
101
+ candidate += timedelta(days=1)
102
+ return candidate
103
+
104
+ def _parse_date_text(self, text: str) -> datetime | None:
105
+ match = _DATE_TEXT_PATTERN.search(text)
106
+ if match is None:
107
+ return None
108
+ month = match.group("month").lower()
109
+ if month not in _MONTHS:
110
+ return None
111
+ hour = int(match.group("hour"))
112
+ if not 1 <= hour <= _HOURS_PER_MERIDIEM:
113
+ return None
114
+ hour = hour % _HOURS_PER_MERIDIEM
115
+ if match.group("meridiem").lower() == _PM:
116
+ hour += _HOURS_PER_MERIDIEM
117
+ try:
118
+ naive = datetime( # noqa: DTZ001
119
+ int(match.group("year")),
120
+ _MONTHS.index(month) + 1,
121
+ int(match.group("day")),
122
+ hour,
123
+ int(match.group("minute")),
124
+ )
125
+ except ValueError:
126
+ return None
127
+ return self._attach_zone(naive, match.group("zone"), match.group("utc"))
128
+
129
+ def _attach_zone(self, naive: datetime, zone: str | None, utc: str | None) -> datetime | None:
130
+ if utc is not None:
131
+ return naive.replace(tzinfo=UTC)
132
+ if zone is not None:
133
+ try:
134
+ return naive.replace(tzinfo=ZoneInfo(zone.strip()))
135
+ except ZoneInfoNotFoundError, ValueError:
136
+ return None
137
+ return self._resolve_local(naive) if self._resolve_local is not None else None
@@ -0,0 +1,335 @@
1
+ """Process-per-turn plugin building blocks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Protocol
7
+
8
+ from shikiru.agents.contracts import (
9
+ AgentSignal,
10
+ FailureKind,
11
+ SessionStarted,
12
+ TurnEnded,
13
+ TurnFailed,
14
+ TurnSpec,
15
+ )
16
+ from shikiru.private import append_private_bytes, make_private_dir
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import AsyncIterator, Callable, Mapping
20
+ from pathlib import Path
21
+
22
+ from shikiru.process import SpawnedProcess, SubprocessRunner
23
+
24
+ STREAM_FILENAME_SUFFIX = ".stream.jsonl"
25
+ STDERR_FILENAME_SUFFIX = ".stderr.log"
26
+ _LINE_ENDINGS = "\r\n"
27
+ EMPTY_OUTPUT = "(empty)"
28
+
29
+
30
+ def stream_log_path(turn_dir: Path, role: str) -> Path:
31
+ """Return where a turn keeps `role`'s raw stdout stream."""
32
+ return turn_dir / f"{role}{STREAM_FILENAME_SUFFIX}"
33
+
34
+
35
+ def stderr_log_path(turn_dir: Path, role: str) -> Path:
36
+ """Return where a turn keeps `role`'s captured stderr."""
37
+ return turn_dir / f"{role}{STDERR_FILENAME_SUFFIX}"
38
+
39
+
40
+ def _append_raw(path: Path, content: bytes) -> None:
41
+ make_private_dir(path.parent)
42
+ append_private_bytes(path, content)
43
+
44
+
45
+ class TurnParser(Protocol):
46
+ """Maps one agent's stdout lines and process exit into normalized signals."""
47
+
48
+ def feed(self, line: str) -> list[AgentSignal]:
49
+ """Map one newline-free stdout line to zero or more signals."""
50
+ ...
51
+
52
+ def finish(
53
+ self, exit_code: int, stderr: str, *, interrupted: bool = False
54
+ ) -> list[AgentSignal]:
55
+ """Map process exit and captured stderr, marking an intentional stop as interrupted."""
56
+ ...
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TurnProcess:
61
+ """One process in a plugin's compact, prompt or retry sequence."""
62
+
63
+ argv: tuple[str, ...]
64
+ cwd: Path
65
+ env: Mapping[str, str]
66
+ parser: TurnParser
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class TurnProcessResult:
71
+ """The parsed outcome of one process in a turn sequence."""
72
+
73
+ signals: tuple[AgentSignal, ...]
74
+ exit_code: int
75
+ stderr: str
76
+
77
+
78
+ class TurnProcessSequence(Protocol):
79
+ """Builds the next process in one turn, using the previous process outcome."""
80
+
81
+ def start(self, spec: TurnSpec) -> TurnProcess:
82
+ """Build the first process, including pre-turn compaction when needed."""
83
+ ...
84
+
85
+ def advance(self, spec: TurnSpec, previous: TurnProcessResult) -> TurnProcess | None:
86
+ """Build a compact or retry process, or finish the turn with `None`."""
87
+ ...
88
+
89
+
90
+ class StreamingTurn:
91
+ """Runs a plugin's process sequence and validates the turn's start and final signal."""
92
+
93
+ def __init__(
94
+ self,
95
+ spec: TurnSpec,
96
+ runner: SubprocessRunner,
97
+ sequence: TurnProcessSequence,
98
+ first_process: TurnProcess,
99
+ process: SpawnedProcess,
100
+ ) -> None:
101
+ self._spec = spec
102
+ self._runner = runner
103
+ self._sequence = sequence
104
+ self._current_process_spec = first_process
105
+ self._process = process
106
+ self._signal_reader_started = False
107
+ self._stopped = False
108
+ self._interrupted = False
109
+ self._session_id: str | None = None
110
+ self._session_started = False
111
+ self._process_session_started = False
112
+ self._signal_count = 0
113
+
114
+ @classmethod
115
+ async def start(
116
+ cls, spec: TurnSpec, runner: SubprocessRunner, sequence: TurnProcessSequence
117
+ ) -> StreamingTurn:
118
+ """Create the first child process and return a live `Turn`."""
119
+ first_process = sequence.start(spec)
120
+ process = await runner.spawn(
121
+ first_process.argv,
122
+ cwd=first_process.cwd,
123
+ env=first_process.env,
124
+ on_started=spec.on_process,
125
+ )
126
+ return cls(spec, runner, sequence, first_process, process)
127
+
128
+ @property
129
+ def pid(self) -> int:
130
+ """Return the operating system id for the process running now."""
131
+ return self._process.pid
132
+
133
+ async def signals(self) -> AsyncIterator[AgentSignal]:
134
+ """Yield normalized signals once, preserving each process's raw output in the turn dir."""
135
+ if self._signal_reader_started:
136
+ message = "signals() can only be consumed once"
137
+ raise RuntimeError(message)
138
+ self._signal_reader_started = True
139
+ process = self._process
140
+ try:
141
+ while True:
142
+ self._process_session_started = False
143
+ execution = _ProcessExecution(
144
+ parser=self._current_process_spec.parser,
145
+ stream_path=self._stream_path,
146
+ stderr_path=self._stderr_path,
147
+ normalize=self._normalize,
148
+ process=process,
149
+ interrupted=lambda: self._interrupted,
150
+ )
151
+ async for signal in execution.signals():
152
+ yield signal
153
+ outcome = execution.outcome
154
+ protocol_failure = self._protocol_failure(execution, outcome, process)
155
+ if protocol_failure is not None:
156
+ yield protocol_failure
157
+ return
158
+ if not self._session_started or self._stopped:
159
+ terminal = self._stopped_or_start_failure(execution, outcome, process)
160
+ if terminal is not None:
161
+ yield terminal
162
+ return
163
+ next_process = self._sequence.advance(self._spec, outcome)
164
+ if next_process is None:
165
+ terminal = execution.terminal or self._unknown_failure(
166
+ "agent process ended without a turn result",
167
+ outcome.exit_code,
168
+ process.stderr_tail,
169
+ )
170
+ yield terminal
171
+ return
172
+ process = await self._runner.spawn(
173
+ next_process.argv,
174
+ cwd=next_process.cwd,
175
+ env=next_process.env,
176
+ on_started=self._spec.on_process,
177
+ )
178
+ self._current_process_spec = next_process
179
+ self._process = process
180
+ finally:
181
+ if process.returncode is None:
182
+ await process.stop()
183
+
184
+ async def stop(self) -> None:
185
+ """Stop the process running now, allowing a later turn to resume its session."""
186
+ self._stopped = True
187
+ self._interrupted = self._interrupted or self._process.returncode is None
188
+ await self._process.stop()
189
+
190
+ @classmethod
191
+ def _unknown_failure(cls, reason: str, exit_code: int, stderr_tail: str) -> TurnFailed:
192
+ return TurnFailed(
193
+ kind=FailureKind.UNKNOWN,
194
+ message=cls._unknown_message(reason, exit_code, stderr_tail),
195
+ resets_at=None,
196
+ )
197
+
198
+ def _protocol_failure(
199
+ self, execution: _ProcessExecution, outcome: TurnProcessResult, process: SpawnedProcess
200
+ ) -> TurnFailed | None:
201
+ if execution.protocol_problem is None:
202
+ return None
203
+ return self._unknown_failure(
204
+ execution.protocol_problem, outcome.exit_code, process.stderr_tail
205
+ )
206
+
207
+ def _stopped_or_start_failure(
208
+ self, execution: _ProcessExecution, outcome: TurnProcessResult, process: SpawnedProcess
209
+ ) -> TurnEnded | TurnFailed | None:
210
+ if not self._session_started:
211
+ if self._interrupted and not outcome.signals:
212
+ return None
213
+ if isinstance(execution.terminal, TurnFailed):
214
+ return execution.terminal
215
+ return self._unknown_failure(
216
+ "agent stream omitted SessionStarted", outcome.exit_code, process.stderr_tail
217
+ )
218
+ if execution.terminal is not None:
219
+ return execution.terminal
220
+ if self._interrupted:
221
+ return None
222
+ return self._unknown_failure(
223
+ "agent process ended without a turn result", outcome.exit_code, process.stderr_tail
224
+ )
225
+
226
+ def _normalize(self, signal: AgentSignal) -> tuple[AgentSignal | None, str | None]:
227
+ if not isinstance(signal, SessionStarted):
228
+ return self._normalize_other(signal)
229
+ if self._process_session_started:
230
+ return None, "process emitted more than one SessionStarted"
231
+ self._process_session_started = True
232
+ return self._normalize_session_start(signal)
233
+
234
+ def _normalize_session_start(
235
+ self, signal: SessionStarted
236
+ ) -> tuple[AgentSignal | None, str | None]:
237
+ if self._session_started:
238
+ if signal.session_id != self._session_id:
239
+ return None, "turn emitted more than one session id"
240
+ return None, None
241
+ if self._signal_count:
242
+ return None, "agent stream emitted a signal before SessionStarted"
243
+ if not signal.session_id.strip():
244
+ return None, "agent stream emitted an empty session id"
245
+ if self._spec.session_id is not None and signal.session_id != self._spec.session_id:
246
+ return None, "resumed turn emitted a different session id"
247
+ self._session_id = signal.session_id
248
+ self._session_started = True
249
+ self._signal_count += 1
250
+ return signal, None
251
+
252
+ def _normalize_other(self, signal: AgentSignal) -> tuple[AgentSignal | None, str | None]:
253
+ if not self._session_started:
254
+ self._signal_count += 1
255
+ return None, "turn emitted a signal before SessionStarted"
256
+ self._signal_count += 1
257
+ return signal, None
258
+
259
+ @staticmethod
260
+ def _unknown_message(reason: str, exit_code: int, stderr_tail: str) -> str:
261
+ tail = stderr_tail or EMPTY_OUTPUT
262
+ return f"{reason}; process exited with status {exit_code}; stderr tail: {tail}"
263
+
264
+ @property
265
+ def _stream_path(self) -> Path:
266
+ return stream_log_path(self._spec.turn_dir, self._spec.role)
267
+
268
+ @property
269
+ def _stderr_path(self) -> Path:
270
+ return stderr_log_path(self._spec.turn_dir, self._spec.role)
271
+
272
+
273
+ class _ProcessExecution:
274
+ def __init__(
275
+ self,
276
+ *,
277
+ parser: TurnParser,
278
+ stream_path: Path,
279
+ stderr_path: Path,
280
+ normalize: Callable[[AgentSignal], tuple[AgentSignal | None, str | None]],
281
+ process: SpawnedProcess,
282
+ interrupted: Callable[[], bool],
283
+ ) -> None:
284
+ self._parser = parser
285
+ self._stream_path = stream_path
286
+ self._stderr_path = stderr_path
287
+ self._normalize = normalize
288
+ self._process = process
289
+ self._interrupted = interrupted
290
+ self._signals: list[AgentSignal] = []
291
+ self.terminal: TurnEnded | TurnFailed | None = None
292
+ self.protocol_problem: str | None = None
293
+ self._outcome: TurnProcessResult | None = None
294
+
295
+ @property
296
+ def outcome(self) -> TurnProcessResult:
297
+ if self._outcome is None:
298
+ message = "process signals must be consumed before reading the outcome"
299
+ raise RuntimeError(message)
300
+ return self._outcome
301
+
302
+ async def signals(self) -> AsyncIterator[AgentSignal]:
303
+ async for raw_line in self._process.stdout_lines():
304
+ _append_raw(self._stream_path, raw_line)
305
+ line = raw_line.decode(errors="replace").rstrip(_LINE_ENDINGS)
306
+ async for signal in self._parsed(self._parser.feed(line)):
307
+ yield signal
308
+ exit_code = await self._process.wait()
309
+ _append_raw(self._stderr_path, self._process.stderr_bytes)
310
+ async for signal in self._parsed(
311
+ self._parser.finish(
312
+ exit_code,
313
+ self._process.stderr,
314
+ interrupted=self._interrupted(),
315
+ )
316
+ ):
317
+ yield signal
318
+ self._outcome = TurnProcessResult(tuple(self._signals), exit_code, self._process.stderr)
319
+
320
+ async def _parsed(self, signals: list[AgentSignal]) -> AsyncIterator[AgentSignal]:
321
+ for signal in signals:
322
+ if self.terminal is not None:
323
+ self.protocol_problem = self.protocol_problem or (
324
+ "process emitted a signal after its terminal signal"
325
+ )
326
+ self._signals.append(signal)
327
+ continue
328
+ if isinstance(signal, (TurnEnded, TurnFailed)):
329
+ self.terminal = signal
330
+ else:
331
+ normalized, problem = self._normalize(signal)
332
+ self.protocol_problem = self.protocol_problem or problem
333
+ if normalized is not None and self.protocol_problem is None:
334
+ yield normalized
335
+ self._signals.append(signal)
@@ -0,0 +1,151 @@
1
+ """The optional agent capability that exposes operator-editable plugin options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import shlex
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum, auto
9
+ from typing import TYPE_CHECKING, Protocol, cast
10
+
11
+ from shikiru.errors import ConfigError
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Sequence
15
+
16
+ from shikiru.agents.contracts import Agent
17
+
18
+
19
+ class SettingKind(StrEnum):
20
+ """How an agent option is edited and written to the project config."""
21
+
22
+ NUMBER = auto()
23
+ CHOICE = auto()
24
+ ARGS = auto()
25
+
26
+
27
+ class AgentSettingError(ConfigError):
28
+ """A rejected agent setting; `key` names the plugin option and `input_id` its editor."""
29
+
30
+ def __init__(self, key: str, message: str, input_id: str = "") -> None:
31
+ """Explain why the text entered for option `key` cannot be written."""
32
+ super().__init__(message)
33
+ self.key = key
34
+ self.input_id = input_id
35
+
36
+
37
+ class AgentOptionsError(ConfigError):
38
+ """A plugin's rejection of its options; `key` names the offending option when known."""
39
+
40
+ def __init__(self, message: str, key: str | None = None) -> None:
41
+ """Explain why a plugin refuses its options, naming option `key` when one is at fault."""
42
+ super().__init__(message)
43
+ self.key = key
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class AgentSetting:
48
+ """One option a plugin offers the Operator, with its default, guidance and accepted values."""
49
+
50
+ key: str
51
+ label: str
52
+ help: str
53
+ kind: SettingKind
54
+ default: float | str | tuple[str, ...] | None
55
+ unit: str = ""
56
+ choices: tuple[str, ...] = ()
57
+ minimum: float | None = None
58
+ maximum: float | None = None
59
+
60
+ @property
61
+ def optional(self) -> bool:
62
+ """Whether the Operator may leave the option unset, so it is not written."""
63
+ return self.kind is SettingKind.CHOICE and self.default is None
64
+
65
+ def display(self) -> str:
66
+ """Return the default as the text the Operator edits."""
67
+ if self.default is None:
68
+ return ""
69
+ if isinstance(self.default, tuple):
70
+ return shlex.join(self.default)
71
+ if isinstance(self.default, str):
72
+ return self.default
73
+ return f"{self.default:g}"
74
+
75
+ def parse(self, raw: str) -> float | str | list[str] | None:
76
+ """Return the value to write for `raw`, `None` for an unset option, or raise."""
77
+ if self.kind is SettingKind.NUMBER:
78
+ return self._number(raw)
79
+ if self.kind is SettingKind.CHOICE:
80
+ return self._choice(raw)
81
+ return self._args(raw)
82
+
83
+ def _number(self, raw: str) -> float:
84
+ try:
85
+ value = float(raw)
86
+ except ValueError:
87
+ value = math.nan
88
+ below = self.minimum is not None and value < self.minimum
89
+ above = self.maximum is not None and value > self.maximum
90
+ if not math.isfinite(value) or below or above:
91
+ raise AgentSettingError(self.key, f"{self.label} must be {self._number_rule()}.")
92
+ return value
93
+
94
+ def _number_rule(self) -> str:
95
+ if self.minimum is not None and self.maximum is not None:
96
+ return f"a number from {self.minimum:g} to {self.maximum:g}"
97
+ if self.minimum is not None:
98
+ return f"a number of at least {self.minimum:g}"
99
+ if self.maximum is not None:
100
+ return f"a number of at most {self.maximum:g}"
101
+ return "a number"
102
+
103
+ def _choice(self, raw: str) -> str | None:
104
+ text = raw.strip()
105
+ if not text and self.optional:
106
+ return None
107
+ if text not in self.choices:
108
+ options = ", ".join(self.choices)
109
+ message = f"{self.label} must be one of: {options}."
110
+ raise AgentSettingError(self.key, message)
111
+ return text
112
+
113
+ def _args(self, raw: str) -> list[str]:
114
+ try:
115
+ return shlex.split(raw)
116
+ except ValueError as error:
117
+ message = f"{self.label} has invalid quoting: {error}."
118
+ raise AgentSettingError(self.key, message) from error
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class AgentSettings:
123
+ """What an agent offers for editing, or why it cannot."""
124
+
125
+ agent: str
126
+ settings: tuple[AgentSetting, ...] = ()
127
+ supported: bool = True
128
+ error: str | None = None
129
+
130
+
131
+ class SettingsProvider(Protocol):
132
+ """Optional agent capability listing the options worth writing to the project config."""
133
+
134
+ def settings(self) -> Sequence[AgentSetting]: ...
135
+
136
+
137
+ def settings_for(agent: Agent) -> tuple[AgentSetting, ...] | None:
138
+ """Return an agent's validated settings, or `None` when it lacks the capability."""
139
+ provider: object = getattr(agent, "settings", None)
140
+ if provider is None:
141
+ return None
142
+ if not callable(provider):
143
+ message = f"agent {agent.name!r} returned an invalid settings capability"
144
+ raise ConfigError(message)
145
+ value = cast("Sequence[object]", provider())
146
+ offered = tuple(item for item in value if isinstance(item, AgentSetting))
147
+ keys = {item.key for item in offered}
148
+ if len(offered) != len(value) or len(keys) != len(offered) or not all(keys):
149
+ message = f"agent {agent.name!r} returned invalid or duplicate settings"
150
+ raise ConfigError(message)
151
+ return offered
@@ -0,0 +1,26 @@
1
+ """The agent plugin contract test kit, imported only by tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from shikiru.agents.testing.contract_suite import (
6
+ AgentContractSuite,
7
+ CompactionCase,
8
+ CompactionScenario,
9
+ InvocationCase,
10
+ RecordedStream,
11
+ ReplayedProcess,
12
+ assert_agent_obligations,
13
+ )
14
+ from shikiru.agents.testing.resume import RESUME_NONCE_PROMPT, RESUME_RECALL_PROMPT
15
+
16
+ __all__ = [
17
+ "RESUME_NONCE_PROMPT",
18
+ "RESUME_RECALL_PROMPT",
19
+ "AgentContractSuite",
20
+ "CompactionCase",
21
+ "CompactionScenario",
22
+ "InvocationCase",
23
+ "RecordedStream",
24
+ "ReplayedProcess",
25
+ "assert_agent_obligations",
26
+ ]