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,107 @@
1
+ """Queues operator messages per role and delivers them into the next turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from itertools import chain
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.paths import MESSAGES_DIR_NAME, queued_messages_dir, run_dir, turns_dir
9
+ from shikiru.private import make_private_dir, write_private_text
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+ from pathlib import Path
14
+
15
+ from shikiru.domain.tasks import Role
16
+
17
+ MESSAGE_NUMBER_WIDTH = 3
18
+ MESSAGE_SUFFIX = ".md"
19
+ MESSAGE_KEY_SEPARATOR = "-"
20
+ PARTIAL_SUFFIX = ".partial"
21
+ COMMAND_KEY_PREFIX = "c"
22
+
23
+
24
+ class OperatorMessages:
25
+ """Files the Operator's messages under `runs/T/messages/<role>/NNN.md`."""
26
+
27
+ def __init__(self, project_root: Path) -> None:
28
+ """Queue messages under `project_root`'s run directories."""
29
+ self._project_root = project_root
30
+
31
+ def queue(self, task_id: str, role: Role, text: str, key: str) -> Path:
32
+ """Write `text` as the role's next numbered message and return its path.
33
+
34
+ The file name carries `key`; a message already filed under `key`, queued or delivered,
35
+ is returned instead of being written again, so a replayed request queues it once.
36
+ """
37
+ existing = self._find(task_id, key)
38
+ if existing is not None:
39
+ return existing
40
+ directory = queued_messages_dir(self._project_root, task_id, role.value)
41
+ make_private_dir(directory)
42
+ number = self._last_number(task_id) + 1
43
+ path = _message_path(directory, number, key)
44
+ _write_atomically(path, text)
45
+ return path
46
+
47
+ def deliver(self, task_id: str, role: Role, turn_dir: Path) -> list[Path]:
48
+ """Move the role's queued messages into `turn_dir/messages/` and return them in order."""
49
+ directory = queued_messages_dir(self._project_root, task_id, role.value)
50
+ queued = _messages(directory) if directory.is_dir() else []
51
+ if not queued:
52
+ return []
53
+ target = turn_dir / MESSAGES_DIR_NAME
54
+ make_private_dir(target)
55
+ return [path.rename(target / path.name) for path in queued]
56
+
57
+ def requeue(self, task_id: str, role: Role, turn_dir: Path) -> None:
58
+ """Return `turn_dir`'s delivered messages to the role's queue, ahead of newer ones.
59
+
60
+ Each message moves back under its own name, and numbers only grow, so the queue order is
61
+ the filing order; one that has not moved yet when the process dies is returned by the
62
+ next call, never lost.
63
+ """
64
+ delivered_dir = turn_dir / MESSAGES_DIR_NAME
65
+ delivered = _messages(delivered_dir) if delivered_dir.is_dir() else []
66
+ if not delivered:
67
+ return
68
+ directory = queued_messages_dir(self._project_root, task_id, role.value)
69
+ make_private_dir(directory)
70
+ for path in delivered:
71
+ path.rename(directory / path.name)
72
+ delivered_dir.rmdir()
73
+
74
+ def _filed(self, task_id: str, pattern: str = f"*{MESSAGE_SUFFIX}") -> Iterator[Path]:
75
+ queued = run_dir(self._project_root, task_id).glob(f"{MESSAGES_DIR_NAME}/*/{pattern}")
76
+ delivered = turns_dir(self._project_root, task_id).glob(f"*/{MESSAGES_DIR_NAME}/{pattern}")
77
+ return chain(queued, delivered)
78
+
79
+ def _last_number(self, task_id: str) -> int:
80
+ return max((_number(path) for path in self._filed(task_id)), default=0)
81
+
82
+ def _find(self, task_id: str, key: str) -> Path | None:
83
+ return next(self._filed(task_id, f"*{MESSAGE_KEY_SEPARATOR}{key}{MESSAGE_SUFFIX}"), None)
84
+
85
+
86
+ def command_message_key(command_id: int) -> str:
87
+ """Return the message key that ties a filed message to the command that queued it."""
88
+ return f"{COMMAND_KEY_PREFIX}{command_id}"
89
+
90
+
91
+ def _number(path: Path) -> int:
92
+ return int(path.stem.partition(MESSAGE_KEY_SEPARATOR)[0])
93
+
94
+
95
+ def _write_atomically(path: Path, text: str) -> None:
96
+ partial = path.with_name(path.name + PARTIAL_SUFFIX)
97
+ write_private_text(partial, text)
98
+ partial.replace(path)
99
+
100
+
101
+ def _messages(directory: Path) -> list[Path]:
102
+ return sorted(directory.glob(f"*{MESSAGE_SUFFIX}"))
103
+
104
+
105
+ def _message_path(directory: Path, number: int, key: str) -> Path:
106
+ name = f"{number:0{MESSAGE_NUMBER_WIDTH}d}{MESSAGE_KEY_SEPARATOR}{key}{MESSAGE_SUFFIX}"
107
+ return directory / name
@@ -0,0 +1,145 @@
1
+ """Read validated reports from one completed turn directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from shikiru.domain.reports import (
12
+ COMMIT_SUBJECT_FIELD,
13
+ DECISION_ACCEPT,
14
+ HELPER_REPORT_FILENAME,
15
+ REPORT_DECISION_FIELD,
16
+ REPORT_FILENAME,
17
+ REPORT_KIND_FILENAME,
18
+ FeedbackVerdict,
19
+ PlanReport,
20
+ Report,
21
+ ReportKind,
22
+ Verdict,
23
+ model_for,
24
+ )
25
+ from shikiru.errors import ShikiruError
26
+
27
+ if TYPE_CHECKING:
28
+ from pathlib import Path
29
+
30
+ _REPORT_FILENAMES = (REPORT_FILENAME, HELPER_REPORT_FILENAME)
31
+ ENGINE_VERDICT_FILENAME = "engine-verdict.json"
32
+ LEGACY_COMMIT_SUBJECT = "chore: legacy report"
33
+
34
+
35
+ class InvalidTurnReportError(ShikiruError):
36
+ """A turn report file exists but its kind, JSON or schema is invalid."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class LoadedReport:
41
+ """A validated report and the kind declared by its turn directory."""
42
+
43
+ kind: ReportKind
44
+ report: Report
45
+
46
+
47
+ def load_turn_report(turn_dir: Path) -> LoadedReport | None:
48
+ """Load a valid report, return `None` only when no report files exist."""
49
+ kind_path = turn_dir / REPORT_KIND_FILENAME
50
+ kind = _read_kind(kind_path)
51
+ if kind is None:
52
+ if any(_file_exists(turn_dir / filename) for filename in _REPORT_FILENAMES):
53
+ message = f"report kind is missing: {kind_path}"
54
+ raise InvalidTurnReportError(message)
55
+ return None
56
+ model = model_for(kind)
57
+ invalid: InvalidTurnReportError | None = None
58
+ for filename in _REPORT_FILENAMES:
59
+ try:
60
+ report = _read_report(turn_dir / filename, model)
61
+ except InvalidTurnReportError as error:
62
+ if invalid is None:
63
+ invalid = error
64
+ continue
65
+ if report is not None:
66
+ return LoadedReport(kind=kind, report=report)
67
+ if invalid is not None:
68
+ raise invalid
69
+ return None
70
+
71
+
72
+ def load_engine_verdict(turn_dir: Path) -> Verdict | None:
73
+ """Load the engine-computed verdict stored beside a turn's agent report."""
74
+ path = turn_dir / ENGINE_VERDICT_FILENAME
75
+ try:
76
+ text = path.read_text()
77
+ except FileNotFoundError:
78
+ return None
79
+ except (OSError, UnicodeError) as error:
80
+ message = f"cannot read engine verdict {path}: {error}"
81
+ raise InvalidTurnReportError(message) from error
82
+ model = (
83
+ FeedbackVerdict
84
+ if _read_kind(turn_dir / REPORT_KIND_FILENAME) is ReportKind.FEEDBACK_VERDICT
85
+ else Verdict
86
+ )
87
+ data = json.loads(text)
88
+ return cast("Verdict", _validate_stored_report(data, model))
89
+
90
+
91
+ def _file_exists(path: Path) -> bool:
92
+ try:
93
+ path.stat()
94
+ except FileNotFoundError:
95
+ return False
96
+ except OSError as error:
97
+ message = f"cannot inspect report file {path}: {error}"
98
+ raise InvalidTurnReportError(message) from error
99
+ return True
100
+
101
+
102
+ def _read_kind(path: Path) -> ReportKind | None:
103
+ try:
104
+ value = path.read_text().strip()
105
+ except FileNotFoundError:
106
+ return None
107
+ except (OSError, UnicodeError) as error:
108
+ message = f"cannot read report kind {path}: {error}"
109
+ raise InvalidTurnReportError(message) from error
110
+ try:
111
+ return ReportKind(value)
112
+ except ValueError as error:
113
+ message = f"invalid report kind in {path}: {value!r}"
114
+ raise InvalidTurnReportError(message) from error
115
+
116
+
117
+ def _read_report(path: Path, model: type[Report]) -> Report | None:
118
+ try:
119
+ text = path.read_text()
120
+ except FileNotFoundError:
121
+ return None
122
+ except (OSError, UnicodeError) as error:
123
+ message = f"cannot read report {path}: {error}"
124
+ raise InvalidTurnReportError(message) from error
125
+ try:
126
+ data = json.loads(text)
127
+ except json.JSONDecodeError as error:
128
+ message = f"invalid JSON in report {path}: {error}"
129
+ raise InvalidTurnReportError(message) from error
130
+ try:
131
+ return _validate_stored_report(data, model)
132
+ except ValidationError as error:
133
+ message = f"invalid report {path}: {error}"
134
+ raise InvalidTurnReportError(message) from error
135
+
136
+
137
+ def _validate_stored_report(data: object, model: type[Report]) -> Report:
138
+ if not isinstance(data, dict) or COMMIT_SUBJECT_FIELD in data:
139
+ return model.model_validate(data)
140
+ fields = cast("dict[str, object]", data)
141
+ if model is PlanReport or (
142
+ model in (Verdict, FeedbackVerdict) and fields.get(REPORT_DECISION_FIELD) == DECISION_ACCEPT
143
+ ):
144
+ return model.model_validate({**fields, COMMIT_SUBJECT_FIELD: LEGACY_COMMIT_SUBJECT})
145
+ return model.model_validate(data)
@@ -0,0 +1,180 @@
1
+ """Task roots: one worktree of the project repository per task, and cleanup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import shutil
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.domain.tasks import RepoState
11
+ from shikiru.errors import StaleTaskBranchError
12
+ from shikiru.paths import EXCLUDE_LINE, run_dir, task_root
13
+ from shikiru.private import make_private_dir
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+
19
+ from shikiru.config.settings import RepoConfig
20
+ from shikiru.workspace.base_refs import BaseRefs
21
+ from shikiru.workspace.caches import CacheCloner
22
+ from shikiru.workspace.git import Git
23
+
24
+ BRANCH_PREFIX = "shikiru/"
25
+ SEED_COMMIT_MESSAGE = "Carry over the tracker changes of the superseded task"
26
+ __all__ = ["EXCLUDE_LINE", "TaskRootBuilder", "branch_name", "run_dir", "slugify", "task_root"]
27
+
28
+ _SLUG_MAX_LENGTH = 40
29
+ _SHORT_SHA = 10
30
+ _NON_ALPHANUMERIC = re.compile(r"[^a-z0-9]+")
31
+ GIT_LINK_NAME = ".git"
32
+ _LOGGER = logging.getLogger(__name__)
33
+
34
+
35
+ def slugify(title: str) -> str:
36
+ """Turn `title` into a stable, filesystem-safe branch slug.
37
+
38
+ Lowercases, replaces every run of non-alphanumeric characters with a single `-`, trims
39
+ leading and trailing `-`, and caps the result at `_SLUG_MAX_LENGTH` characters.
40
+ """
41
+ collapsed = _NON_ALPHANUMERIC.sub("-", title.lower()).strip("-")
42
+ return collapsed[:_SLUG_MAX_LENGTH].strip("-")
43
+
44
+
45
+ def branch_name(task_id: str, title: str) -> str:
46
+ """Build the deterministic task branch name `shikiru/T-n-<slug>`."""
47
+ return f"{BRANCH_PREFIX}{task_id}-{slugify(title)}"
48
+
49
+
50
+ class TaskRootBuilder:
51
+ """Builds and tears down `.shikiru/worktrees/T-n/` for a task."""
52
+
53
+ def __init__(
54
+ self,
55
+ git: Git,
56
+ project_root: Path,
57
+ repo: RepoConfig,
58
+ cache_cloner: CacheCloner,
59
+ base_refs: BaseRefs,
60
+ *,
61
+ trackers: Sequence[str],
62
+ ) -> None:
63
+ """Build task roots starting from the ref `base_refs` picks, cloning configured caches."""
64
+ self._git = git
65
+ self._project_root = project_root
66
+ self._repo = repo
67
+ self._trackers = trackers
68
+ self._cache_cloner = cache_cloner
69
+ self._base_refs = base_refs
70
+ self._warned_cow_unsupported = False
71
+
72
+ async def build(self, task_id: str, title: str, *, seed_branch: str | None = None) -> RepoState:
73
+ """Build (or reuse) `task_id`'s task root: one worktree of the project repository.
74
+
75
+ A new root of a replacement task gets, as its first commit, the tracker changes of
76
+ `seed_branch` (the superseded task's branch) and nothing else of it.
77
+
78
+ Idempotent: calling this again with the same arguments creates nothing twice, whether
79
+ the branch, the worktree or both already exist.
80
+ """
81
+ root = task_root(self._project_root, task_id)
82
+ make_private_dir(root.parent)
83
+ branch = branch_name(task_id, title)
84
+ project = self._project_root
85
+ repo = self._repo
86
+ base = repo.base if repo.base is not None else await self._git.current_branch(project)
87
+ base_ref = await self._base_refs.resolve(base)
88
+ base_sha = await self._git.rev_parse(project, base_ref)
89
+ await self._git.append_to_exclude(project, EXCLUDE_LINE)
90
+ if not await self._git.worktree_exists(project, root):
91
+ if await self._git.branch_exists(project, branch):
92
+ await self._require_fresh_branch(branch, base_sha)
93
+ await self._git.worktree_add_existing_branch(project, root, branch)
94
+ else:
95
+ await self._git.worktree_add(project, root, new_branch=branch, base=base_ref)
96
+ if seed_branch is not None:
97
+ await self._seed_trackers(root, branch, base_ref, seed_branch)
98
+ if repo.caches:
99
+ clone_result = await self._cache_cloner.clone(
100
+ project, root, repo.caches, await self._git.ignored_paths(project)
101
+ )
102
+ if clone_result.warning is not None and not self._warned_cow_unsupported:
103
+ _LOGGER.warning("%s", clone_result.warning)
104
+ self._warned_cow_unsupported = True
105
+ return RepoState(base=base, base_ref=base_ref, base_sha=base_sha, branch=branch)
106
+
107
+ async def _require_fresh_branch(self, branch: str, base_sha: str) -> None:
108
+ if await self._git.rev_parse(self._project_root, branch) != base_sha:
109
+ message = (
110
+ f"branch {branch} already exists and is not at the base {base_sha[:_SHORT_SHA]}: "
111
+ f"it holds an earlier task's work; inspect it, then delete it "
112
+ f"(git branch -D {branch}) or rename it and submit the task again"
113
+ )
114
+ raise StaleTaskBranchError(message)
115
+
116
+ async def _seed_trackers(
117
+ self, root: Path, branch: str, base_ref: str, seed_branch: str
118
+ ) -> None:
119
+ project = self._project_root
120
+ if not self._trackers or not await self._git.branch_exists(project, seed_branch):
121
+ return
122
+ try:
123
+ patch = await self._git.diff_patch(
124
+ project, f"{base_ref}...{seed_branch}", self._trackers
125
+ )
126
+ if patch:
127
+ await self._git.apply_patch(root, patch)
128
+ await self._git.commit(root, SEED_COMMIT_MESSAGE)
129
+ except Exception:
130
+ await self._git.worktree_remove(project, root)
131
+ await self._git.delete_branch(project, branch)
132
+ raise
133
+
134
+ async def _holds_unintegrated_work(self, root: Path, repo: RepoState) -> bool:
135
+ if await self._git.worktree_removal_pending(self._project_root, root):
136
+ return False
137
+ if await self._git.head_branch(root) != repo.branch:
138
+ return True
139
+ return bool(await self._git.status_porcelain(root))
140
+
141
+ async def cleanup(
142
+ self,
143
+ task_id: str,
144
+ repo: RepoState | None,
145
+ *,
146
+ integrated: bool = False,
147
+ keep_work: bool = False,
148
+ ) -> Path | None:
149
+ """Remove the task's worktree, its merged branch, and last its task root.
150
+
151
+ The branch is merged when it is an ancestor of the local base or when the caller says
152
+ the task is `integrated` (a pull request merged on the remote leaves the local base
153
+ behind, and a squash leaves the branch out of its history). Idempotent and tolerant of
154
+ a worktree already removed. Never deletes an unmerged branch, never follows a symlink
155
+ out of the task root, and never erases unintegrated work: a worktree whose `HEAD` is off
156
+ the task branch, one with tracked or untracked changes (git-ignored files excepted) and
157
+ its branch stay, and so does a root that holds anything but the git link once its
158
+ worktree metadata is gone (git cannot tell what is uncommitted there); the kept task
159
+ root is returned. With `keep_work` the caller knows the branch holds commits that are
160
+ nowhere else: its worktree and branch stay. A removal that began (it passed those checks)
161
+ and was interrupted by a failure or timeout leaves a half-deleted worktree that the next
162
+ cleanup removes without taking the deletions for uncommitted work.
163
+ """
164
+ project = self._project_root
165
+ root = task_root(project, task_id)
166
+ if repo is not None:
167
+ if await self._git.worktree_exists(project, root):
168
+ if keep_work or await self._holds_unintegrated_work(root, repo):
169
+ return root
170
+ await self._git.worktree_remove(project, root)
171
+ if await self._git.branch_exists(project, repo.branch) and (
172
+ integrated
173
+ or await self._git.merge_base_is_ancestor(project, repo.branch, repo.base)
174
+ ):
175
+ await self._git.delete_branch(project, repo.branch)
176
+ if root.exists():
177
+ if repo is not None and any(entry.name != GIT_LINK_NAME for entry in root.iterdir()):
178
+ return root
179
+ shutil.rmtree(root)
180
+ return None