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,416 @@
1
+ """Bounded reads of recorded normalized signal files for task evidence pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from shikiru.domain.tasks import Role
11
+ from shikiru.domain.timeline import (
12
+ MISSING_SIGNAL_SUMMARY,
13
+ TIMELINE_ACTIVITY_CATEGORY,
14
+ TIMELINE_AGENT_CATEGORY,
15
+ TIMELINE_ALL_CATEGORY,
16
+ TIMELINE_DECISION_CATEGORY,
17
+ TIMELINE_ENGINE_CATEGORY,
18
+ TIMELINE_EVIDENCE_CATEGORY,
19
+ TIMELINE_MESSAGE_CATEGORY,
20
+ TIMELINE_SIGNAL_CATEGORY,
21
+ TIMELINE_SOURCE_AGENT,
22
+ TIMELINE_TOOL_CATEGORY,
23
+ TIMELINE_TRANSITION_CATEGORY,
24
+ TIMELINE_USAGE_CATEGORY,
25
+ ParsedSignal,
26
+ parse_signal_row,
27
+ )
28
+ from shikiru.domain.timeline_phrases import GLYPH_ATTENTION, Phrase, Tone
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Mapping, Sequence
32
+ from pathlib import Path
33
+
34
+ CURSOR_VERSION = 1
35
+ SIGNAL_LINE_BLOCK_BYTES = 65536
36
+ CURSOR_POSITION_FIELDS = 2
37
+ TIMELINE_CURSOR_ERROR = "task evidence cursor is invalid"
38
+ TIMELINE_CURSOR_FILTER_ERROR = "task evidence cursor belongs to different filters"
39
+ TIMELINE_MISSING_DETAIL = "No recorded normalized signal file is available for this role."
40
+ TIMELINE_READ_ERROR_SUMMARY = "Could not read recorded signal evidence"
41
+ TIMELINE_READ_ERROR_PREFIX = "unavailable:"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TimelineFilters:
46
+ """Filters applied consistently to engine events and normalized signal evidence."""
47
+
48
+ role: str | None
49
+ turn: str | None
50
+ category: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class TimelineCursor:
55
+ """Positions immediately after the newest rows already shown from each source."""
56
+
57
+ event_before: tuple[str, int] | None
58
+ signal_before: Mapping[str, int | None]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class TimelineItem:
63
+ """One evidence row with stable identity and its source ordering key."""
64
+
65
+ identity: str
66
+ timestamp: datetime | None
67
+ source: str
68
+ category: str
69
+ summary: str
70
+ phrase: Phrase
71
+ detail: str
72
+ role: str | None
73
+ turn: str | None
74
+ event_id: int | None = None
75
+ signal_offset: int | None = None
76
+ signal_end_offset: int | None = None
77
+ incomplete: bool = False
78
+ timestamp_error: str | None = None
79
+ listed: bool = True
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class SignalBatch:
84
+ """A bounded signal page and a position that safely replays its first row."""
85
+
86
+ items: tuple[TimelineItem, ...]
87
+ replay_offset: int | None
88
+ scan_offset: int
89
+ has_more: bool = False
90
+
91
+
92
+ def encode_cursor(cursor: TimelineCursor, filters: TimelineFilters) -> str:
93
+ """Serialize a source-position cursor with the filters that define its ordering."""
94
+ encoded = {
95
+ "version": CURSOR_VERSION,
96
+ "event_before": list(cursor.event_before) if cursor.event_before is not None else None,
97
+ "signal_before": dict(cursor.signal_before),
98
+ "filters": {"role": filters.role, "turn": filters.turn, "category": filters.category},
99
+ }
100
+ return json.dumps(encoded, separators=(",", ":"), sort_keys=True)
101
+
102
+
103
+ def decode_cursor(
104
+ token: str,
105
+ *,
106
+ filters: TimelineFilters,
107
+ roles: Sequence[str],
108
+ ) -> TimelineCursor:
109
+ """Validate an opaque cursor before continuing a filtered evidence page."""
110
+ data = _cursor_mapping(token)
111
+ expected_filters = {"role": filters.role, "turn": filters.turn, "category": filters.category}
112
+ if data.get("filters") != expected_filters:
113
+ raise ValueError(TIMELINE_CURSOR_FILTER_ERROR)
114
+ signal_before = _signal_positions(data, filters, roles)
115
+ event_before = _event_position(data)
116
+ return TimelineCursor(event_before=event_before, signal_before=signal_before)
117
+
118
+
119
+ def _cursor_mapping(token: str) -> dict[str, object]:
120
+ try:
121
+ raw_data: object = json.loads(token)
122
+ except json.JSONDecodeError as error:
123
+ raise ValueError(TIMELINE_CURSOR_ERROR) from error
124
+ if not isinstance(raw_data, dict):
125
+ raise TypeError(TIMELINE_CURSOR_ERROR)
126
+ untyped_data = cast("dict[object, object]", raw_data)
127
+ if not all(isinstance(key, str) for key in untyped_data):
128
+ raise ValueError(TIMELINE_CURSOR_ERROR)
129
+ data = cast("dict[str, object]", untyped_data)
130
+ if type(data.get("version")) is not int or data.get("version") != CURSOR_VERSION:
131
+ raise ValueError(TIMELINE_CURSOR_ERROR)
132
+ return data
133
+
134
+
135
+ def _signal_positions(
136
+ data: Mapping[str, object], filters: TimelineFilters, roles: Sequence[str]
137
+ ) -> dict[str, int | None]:
138
+ raw_signal_before = data.get("signal_before")
139
+ valid_roles = {item.value for item in Role}
140
+ if not isinstance(raw_signal_before, dict):
141
+ raise TypeError(TIMELINE_CURSOR_ERROR)
142
+ untyped_positions = cast("dict[object, object]", raw_signal_before)
143
+ if not all(isinstance(role, str) for role in untyped_positions):
144
+ raise TypeError(TIMELINE_CURSOR_ERROR)
145
+ signal_before = cast("dict[str, object]", untyped_positions)
146
+ if not set(signal_before) <= valid_roles:
147
+ raise ValueError(TIMELINE_CURSOR_ERROR)
148
+ if not set(signal_before) <= set(roles):
149
+ raise ValueError(TIMELINE_CURSOR_ERROR)
150
+ if filters.role is not None and set(signal_before) != set(roles):
151
+ raise ValueError(TIMELINE_CURSOR_ERROR)
152
+ positions: dict[str, int | None] = {}
153
+ for role, offset in signal_before.items():
154
+ if offset is not None and (
155
+ not isinstance(offset, int) or isinstance(offset, bool) or offset < -1
156
+ ):
157
+ raise ValueError(TIMELINE_CURSOR_ERROR)
158
+ positions[role] = offset
159
+ return positions
160
+
161
+
162
+ def _event_position(data: Mapping[str, object]) -> tuple[str, int] | None:
163
+ value = data.get("event_before")
164
+ if value is None:
165
+ return None
166
+ if not isinstance(value, list):
167
+ raise TypeError(TIMELINE_CURSOR_ERROR)
168
+ position = cast("list[object]", value)
169
+ if len(position) != CURSOR_POSITION_FIELDS:
170
+ raise ValueError(TIMELINE_CURSOR_ERROR)
171
+ timestamp_value, event_id = position
172
+ if not isinstance(timestamp_value, str):
173
+ raise TypeError(TIMELINE_CURSOR_ERROR)
174
+ if not isinstance(event_id, int) or isinstance(event_id, bool) or event_id < 1:
175
+ raise ValueError(TIMELINE_CURSOR_ERROR)
176
+ try:
177
+ timestamp = datetime.fromisoformat(timestamp_value)
178
+ except ValueError as error:
179
+ raise ValueError(TIMELINE_CURSOR_ERROR) from error
180
+ if timestamp.tzinfo is None:
181
+ raise ValueError(TIMELINE_CURSOR_ERROR)
182
+ return timestamp_value, event_id
183
+
184
+
185
+ def initial_cursor(paths: Mapping[str, Path]) -> TimelineCursor:
186
+ """Capture each normalized source's current tail for a stable latest-page read."""
187
+ positions: dict[str, int | None] = {}
188
+ for role, path in paths.items():
189
+ try:
190
+ positions[role] = path.stat().st_size
191
+ except OSError:
192
+ positions[role] = None
193
+ return TimelineCursor(event_before=None, signal_before=positions)
194
+
195
+
196
+ def read_signal_batch(
197
+ path: Path,
198
+ *,
199
+ role: str,
200
+ before_offset: int | None,
201
+ filters: TimelineFilters,
202
+ limit: int,
203
+ scan_limit: int,
204
+ ) -> SignalBatch:
205
+ """Read at most `limit` matching signal rows backwards without retaining the file."""
206
+ if before_offset == -1:
207
+ return SignalBatch(items=(), replay_offset=-1, scan_offset=-1)
208
+ try:
209
+ snapshot = _signal_snapshot(path, before_offset)
210
+ if snapshot is None:
211
+ return _missing_batch(role, filters)
212
+ return _scan_signal_file(
213
+ path,
214
+ role=role,
215
+ end_position=snapshot,
216
+ filters=filters,
217
+ limit=limit,
218
+ scan_limit=scan_limit,
219
+ )
220
+ except (OSError, ValueError) as error:
221
+ return _unavailable_batch(role, filters, error)
222
+
223
+
224
+ def _signal_snapshot(path: Path, before_offset: int | None) -> int | None:
225
+ try:
226
+ file_size = path.stat().st_size
227
+ except FileNotFoundError:
228
+ return None
229
+ end_position = file_size if before_offset is None else before_offset
230
+ if end_position > file_size:
231
+ message = f"recorded signal file changed before its saved cursor: {path}"
232
+ raise ValueError(message)
233
+ return end_position
234
+
235
+
236
+ def _missing_batch(role: str, filters: TimelineFilters) -> SignalBatch:
237
+ missing = _missing_signal(role)
238
+ if _matches_signal(missing, filters):
239
+ return SignalBatch(items=(missing,), replay_offset=None, scan_offset=0)
240
+ return SignalBatch(items=(), replay_offset=0, scan_offset=0)
241
+
242
+
243
+ def _unavailable_batch(
244
+ role: str, filters: TimelineFilters, error: OSError | ValueError
245
+ ) -> SignalBatch:
246
+ unavailable = _unavailable_signal(role, error)
247
+ if _matches_signal(unavailable, filters):
248
+ return SignalBatch(items=(unavailable,), replay_offset=-1, scan_offset=-1)
249
+ return SignalBatch(items=(), replay_offset=-1, scan_offset=-1)
250
+
251
+
252
+ def _scan_signal_file(
253
+ path: Path,
254
+ *,
255
+ role: str,
256
+ end_position: int,
257
+ filters: TimelineFilters,
258
+ limit: int,
259
+ scan_limit: int,
260
+ ) -> SignalBatch:
261
+ items: list[TimelineItem] = []
262
+ position = end_position
263
+ scanned = 0
264
+ while position > 0 and len(items) < limit and scanned < scan_limit:
265
+ parsed = _previous_signal(path, role, position)
266
+ if parsed is None:
267
+ position = 0
268
+ break
269
+ position = parsed.start_offset
270
+ scanned += 1
271
+ item = _timeline_item(parsed)
272
+ if _matches_signal(item, filters):
273
+ items.append(item)
274
+ replay_offset = items[0].signal_end_offset if items else position
275
+ scan_offset = position
276
+ return SignalBatch(
277
+ items=tuple(items),
278
+ replay_offset=replay_offset,
279
+ scan_offset=scan_offset,
280
+ has_more=position > 0,
281
+ )
282
+
283
+
284
+ def signal_category_is_requested(category: str) -> bool:
285
+ """Whether an evidence category can contain normalized agent signal rows."""
286
+ return category in {
287
+ TIMELINE_ALL_CATEGORY,
288
+ TIMELINE_AGENT_CATEGORY,
289
+ TIMELINE_SIGNAL_CATEGORY,
290
+ TIMELINE_TOOL_CATEGORY,
291
+ TIMELINE_MESSAGE_CATEGORY,
292
+ TIMELINE_USAGE_CATEGORY,
293
+ TIMELINE_EVIDENCE_CATEGORY,
294
+ TIMELINE_TRANSITION_CATEGORY,
295
+ }
296
+
297
+
298
+ def event_category_is_requested(category: str) -> bool:
299
+ """Whether an evidence category can contain engine event rows."""
300
+ return category in {
301
+ TIMELINE_ALL_CATEGORY,
302
+ TIMELINE_ACTIVITY_CATEGORY,
303
+ TIMELINE_ENGINE_CATEGORY,
304
+ TIMELINE_DECISION_CATEGORY,
305
+ TIMELINE_TRANSITION_CATEGORY,
306
+ }
307
+
308
+
309
+ def _previous_signal(
310
+ path: Path,
311
+ role: str,
312
+ end_position: int,
313
+ ) -> ParsedSignal | None:
314
+ with path.open("rb") as handle:
315
+ handle.seek(end_position - 1)
316
+ last_byte = handle.read(1)
317
+ incomplete = last_byte != b"\n"
318
+ line_end = end_position - 1 if last_byte == b"\n" else end_position
319
+ if line_end <= 0:
320
+ return None
321
+ search_end = line_end
322
+ line_start = 0
323
+ while search_end > 0:
324
+ block_start = max(search_end - SIGNAL_LINE_BLOCK_BYTES, 0)
325
+ handle.seek(block_start)
326
+ block = handle.read(search_end - block_start)
327
+ delimiter = block.rfind(b"\n")
328
+ if delimiter >= 0:
329
+ line_start = block_start + delimiter + 1
330
+ break
331
+ search_end = block_start
332
+ handle.seek(line_start)
333
+ raw = handle.read(line_end - line_start)
334
+ return parse_signal_row(
335
+ raw,
336
+ role=role,
337
+ start_offset=line_start,
338
+ end_offset=end_position,
339
+ incomplete=incomplete,
340
+ )
341
+
342
+
343
+ def _timeline_item(signal: ParsedSignal) -> TimelineItem:
344
+ timestamp_error = signal.timestamp_error
345
+ if signal.incomplete:
346
+ timestamp_error = "JSONL row has not ended with a newline"
347
+ return TimelineItem(
348
+ identity=f"signal:{signal.role}:{signal.start_offset}",
349
+ timestamp=signal.timestamp,
350
+ source=TIMELINE_SOURCE_AGENT,
351
+ category=signal.category,
352
+ summary=signal.summary,
353
+ phrase=signal.phrase,
354
+ detail=signal.detail,
355
+ role=signal.role,
356
+ turn=signal.turn,
357
+ signal_offset=signal.start_offset,
358
+ signal_end_offset=signal.end_offset,
359
+ incomplete=signal.incomplete,
360
+ timestamp_error=timestamp_error,
361
+ listed=signal.listed,
362
+ )
363
+
364
+
365
+ def _missing_signal(role: str) -> TimelineItem:
366
+ return TimelineItem(
367
+ identity=f"missing:{role}",
368
+ timestamp=None,
369
+ source=TIMELINE_SOURCE_AGENT,
370
+ category=TIMELINE_EVIDENCE_CATEGORY,
371
+ summary=MISSING_SIGNAL_SUMMARY,
372
+ phrase=Phrase(MISSING_SIGNAL_SUMMARY),
373
+ detail=TIMELINE_MISSING_DETAIL,
374
+ role=role,
375
+ turn=None,
376
+ signal_offset=0,
377
+ signal_end_offset=None,
378
+ timestamp_error="recorded timestamp is missing",
379
+ listed=False,
380
+ )
381
+
382
+
383
+ def _unavailable_signal(role: str, error: OSError | ValueError) -> TimelineItem:
384
+ return TimelineItem(
385
+ identity=f"{TIMELINE_READ_ERROR_PREFIX}{role}",
386
+ timestamp=None,
387
+ source=TIMELINE_SOURCE_AGENT,
388
+ category=TIMELINE_EVIDENCE_CATEGORY,
389
+ summary=TIMELINE_READ_ERROR_SUMMARY,
390
+ phrase=Phrase(TIMELINE_READ_ERROR_SUMMARY, GLYPH_ATTENTION, Tone.WARNING),
391
+ detail=str(error),
392
+ role=role,
393
+ turn=None,
394
+ signal_offset=None,
395
+ signal_end_offset=None,
396
+ timestamp_error="recorded timestamp is unavailable because the source could not be read",
397
+ )
398
+
399
+
400
+ def _matches_signal(item: TimelineItem, filters: TimelineFilters) -> bool:
401
+ if filters.role is not None and filters.role != item.role:
402
+ return False
403
+ if item.identity.startswith(TIMELINE_READ_ERROR_PREFIX):
404
+ return filters.category in {
405
+ TIMELINE_ALL_CATEGORY,
406
+ TIMELINE_AGENT_CATEGORY,
407
+ TIMELINE_SIGNAL_CATEGORY,
408
+ TIMELINE_EVIDENCE_CATEGORY,
409
+ }
410
+ if filters.turn is not None and filters.turn != item.turn:
411
+ return False
412
+ if filters.category == TIMELINE_AGENT_CATEGORY:
413
+ return item.listed
414
+ if filters.category in {TIMELINE_ALL_CATEGORY, TIMELINE_SIGNAL_CATEGORY}:
415
+ return True
416
+ return filters.category == item.category
@@ -0,0 +1,62 @@
1
+ """Render keys and values as TOML text that reads back unchanged."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ from shikiru.errors import ConfigError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Mapping, Sequence
13
+
14
+ BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")
15
+ SHORT_ESCAPES: Mapping[str, str] = {
16
+ "\b": "\\b",
17
+ "\t": "\\t",
18
+ "\n": "\\n",
19
+ "\f": "\\f",
20
+ "\r": "\\r",
21
+ '"': '\\"',
22
+ "\\": "\\\\",
23
+ }
24
+ LAST_CONTROL_CODE = 0x1F
25
+ DELETE_CODE = 0x7F
26
+ VALUE_SEPARATOR = ", "
27
+
28
+
29
+ def toml_string(text: str) -> str:
30
+ """Return `text` as a TOML basic string, keeping non-ASCII characters literal."""
31
+ parts: list[str] = []
32
+ for character in text:
33
+ code = ord(character)
34
+ if character in SHORT_ESCAPES:
35
+ parts.append(SHORT_ESCAPES[character])
36
+ elif code <= LAST_CONTROL_CODE or code == DELETE_CODE:
37
+ parts.append(f"\\u{code:04X}")
38
+ else:
39
+ parts.append(character)
40
+ return f'"{"".join(parts)}"'
41
+
42
+
43
+ def toml_key(key: str) -> str:
44
+ """Return `key` bare when TOML allows it, otherwise as one quoted key."""
45
+ return key if BARE_KEY.fullmatch(key) else toml_string(key)
46
+
47
+
48
+ def toml_value(value: object) -> str:
49
+ """Return a bool, int, finite float, string or list of those as TOML text."""
50
+ if isinstance(value, bool):
51
+ return str(value).lower()
52
+ if isinstance(value, int):
53
+ return str(value)
54
+ if isinstance(value, float) and math.isfinite(value):
55
+ return repr(value)
56
+ if isinstance(value, str):
57
+ return toml_string(value)
58
+ if isinstance(value, (list, tuple)):
59
+ items = cast("Sequence[object]", value)
60
+ return f"[{VALUE_SEPARATOR.join(toml_value(item) for item in items)}]"
61
+ message = f"cannot write {value!r} to the project config"
62
+ raise ConfigError(message)
shikiru/palette.py ADDED
@@ -0,0 +1,18 @@
1
+ """Shared semantic colors for terminal and dashboard surfaces."""
2
+
3
+ BACKGROUND_COLOR = "#11151b"
4
+ SURFACE_COLOR = "#171d25"
5
+ RAISED_SURFACE_COLOR = "#171d25"
6
+ SELECTED_SURFACE_COLOR = "#233340"
7
+ DETAIL_SURFACE_COLOR = "#1c2530"
8
+ NEUTRAL_BORDER_COLOR = "#3d4654"
9
+ PRIMARY_TEXT_COLOR = "#dce2e9"
10
+ SECONDARY_TEXT_COLOR = "#9aa5b5"
11
+ STEEL_BLUE = "#83bddd"
12
+ SAGE = "#90cba6"
13
+ AMBER = "#dfb16c"
14
+ DUSTY_ROSE = "#e3a0a0"
15
+ SCROLLBAR_THUMB_COLOR = "#344b5d"
16
+ SCROLLBAR_THUMB_HOVER_COLOR = "#415e72"
17
+ SCROLLBAR_THUMB_ACTIVE_COLOR = STEEL_BLUE
18
+ SCROLLBAR_TRACK_COLOR = "#000008"
shikiru/paths.py ADDED
@@ -0,0 +1,91 @@
1
+ """Project-local paths defined by the runtime layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from pathlib import Path
9
+
10
+ SHIKIRU_DIR_NAME = ".shikiru"
11
+ PROJECT_CONFIG_RELATIVE_PATH = f"{SHIKIRU_DIR_NAME}/config.toml"
12
+ PROMPT_OVERRIDES_RELATIVE_PATH = f"{SHIKIRU_DIR_NAME}/prompts"
13
+ STATE_DB_FILENAME = "state.db"
14
+ SCHEMA_BACKUPS_DIR_NAME = "backups"
15
+ ENGINE_LOCK_FILENAME = "engine.lock"
16
+ ENGINE_LOG_DIR = "logs"
17
+ ENGINE_LOG_FILENAME = "engine.log"
18
+ ENGINE_OUTPUT_FILENAME = "engine.out"
19
+ WORKTREES_DIR_NAME = "worktrees"
20
+ RUNS_DIR_NAME = "runs"
21
+ TURNS_DIR_NAME = "turns"
22
+ MESSAGES_DIR_NAME = "messages"
23
+ PROBE_DIR_NAME = "probe"
24
+ PROBE_REPO_DIR_NAME = "repo"
25
+ PROBE_RUN_DIR_NAME = "run"
26
+ EXCLUDE_LINE = f"/{SHIKIRU_DIR_NAME}/"
27
+
28
+
29
+ def project_state_dir(project_root: Path) -> Path:
30
+ """Return the project's `.shikiru/` state directory."""
31
+ return project_root / SHIKIRU_DIR_NAME
32
+
33
+
34
+ def project_config_path(project_root: Path) -> Path:
35
+ """Return the project's settings file path."""
36
+ return project_root / PROJECT_CONFIG_RELATIVE_PATH
37
+
38
+
39
+ def state_db_path(project_root: Path) -> Path:
40
+ """Return the project's SQLite state database path."""
41
+ return project_state_dir(project_root) / STATE_DB_FILENAME
42
+
43
+
44
+ def engine_lock_path(project_root: Path) -> Path:
45
+ """Return the project's engine liveness lock path."""
46
+ return project_state_dir(project_root) / ENGINE_LOCK_FILENAME
47
+
48
+
49
+ def engine_log_path(project_root: Path) -> Path:
50
+ """Return the project's engine log path."""
51
+ return project_state_dir(project_root) / ENGINE_LOG_DIR / ENGINE_LOG_FILENAME
52
+
53
+
54
+ def engine_output_path(project_root: Path) -> Path:
55
+ """Return the file holding the stdout and stderr of the engine process `start` spawned."""
56
+ return project_state_dir(project_root) / ENGINE_LOG_DIR / ENGINE_OUTPUT_FILENAME
57
+
58
+
59
+ def runs_root(project_root: Path) -> Path:
60
+ """Return the `.shikiru/runs/` directory holding every task's run directory."""
61
+ return project_state_dir(project_root) / RUNS_DIR_NAME
62
+
63
+
64
+ def run_dir(project_root: Path, task_id: str) -> Path:
65
+ """Return `task_id`'s `.shikiru/runs/` directory."""
66
+ return runs_root(project_root) / task_id
67
+
68
+
69
+ def turns_dir(project_root: Path, task_id: str) -> Path:
70
+ """Return `task_id`'s turn artifacts directory."""
71
+ return run_dir(project_root, task_id) / TURNS_DIR_NAME
72
+
73
+
74
+ def task_root(project_root: Path, task_id: str) -> Path:
75
+ """Return `task_id`'s `.shikiru/worktrees/` directory."""
76
+ return project_state_dir(project_root) / WORKTREES_DIR_NAME / task_id
77
+
78
+
79
+ def queued_messages_dir(project_root: Path, task_id: str, role: str) -> Path:
80
+ """Return the directory holding `role`'s queued operator messages for `task_id`."""
81
+ return run_dir(project_root, task_id) / MESSAGES_DIR_NAME / role
82
+
83
+
84
+ def probe_repo_path(project_root: Path) -> Path:
85
+ """Return the scratch repository `doctor --probe` runs its turns in."""
86
+ return project_state_dir(project_root) / PROBE_DIR_NAME / PROBE_REPO_DIR_NAME
87
+
88
+
89
+ def probe_run_dir(project_root: Path) -> Path:
90
+ """Return the run directory `doctor --probe` keeps its turn artifacts in."""
91
+ return project_state_dir(project_root) / PROBE_DIR_NAME / PROBE_RUN_DIR_NAME
File without changes
@@ -0,0 +1 @@
1
+ """Built-in agent plugins."""
@@ -0,0 +1,5 @@
1
+ """Claude Code agent plugin."""
2
+
3
+ from shikiru.plugins.agents.claude.agent import ClaudeAgent, create
4
+
5
+ __all__ = ["ClaudeAgent", "create"]