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,160 @@
1
+ """Where task roots start from: the local or the verified fetched remote base."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TYPE_CHECKING, Protocol
7
+
8
+ from shikiru.errors import CommandFailed, CommandTimeoutError, ShikiruError
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ from shikiru.workspace.git import Git
14
+
15
+ BASE_REF_PREFIX = "refs/shikiru/bases/"
16
+ _HEADS_PREFIX = "refs/heads/"
17
+ _REMOTE_TRACKING_PREFIX = "refs/remotes/"
18
+ _MISSING_REF_SHA = "0" * 40
19
+ _DIVERGED_REASON = "it has local commits the remote base does not contain"
20
+ _NOT_MOVED_REASON = "git left the branch where it was"
21
+
22
+
23
+ class MergedBaseUnavailable(ShikiruError):
24
+ """A merged pull request's commit is not on the fetched remote base."""
25
+
26
+ def __init__(self, base: str, commit: str | None) -> None:
27
+ """Name the base and the merge commit that could not be verified on it."""
28
+ super().__init__(f"merge commit {commit} is not on the fetched remote base {base}")
29
+
30
+
31
+ class BaseFetchFailed(ShikiruError):
32
+ """The remote base could not be fetched: the remote is unreachable or the fetch timed out."""
33
+
34
+ def __init__(self, base: str, remote: str, cause: ShikiruError) -> None:
35
+ """Name the `base` and `remote` and say why the fetch failed."""
36
+ super().__init__(f"cannot fetch {base} from {remote}: {cause}")
37
+
38
+
39
+ class LocalBaseBlocked(ShikiruError):
40
+ """The local base branch cannot fast-forward to the remote tip without losing work."""
41
+
42
+ def __init__(self, base: str, tip: str, reason: str) -> None:
43
+ """Name the `base`, the remote `tip` it could not reach and why."""
44
+ super().__init__(f"local base {base} cannot fast-forward to {tip}: {reason}")
45
+
46
+
47
+ class BaseRefs(Protocol):
48
+ """The single rule choosing the git ref a task root starts from."""
49
+
50
+ async def resolve(self, base: str) -> str:
51
+ """Return the ref new task roots of `base` start from and rebase onto."""
52
+ ...
53
+
54
+
55
+ class LocalBaseRefs:
56
+ """`integration = "merge"`: work lands on the local base branch, so it is the ref."""
57
+
58
+ async def resolve(self, base: str) -> str:
59
+ """Return `base` itself."""
60
+ return base
61
+
62
+
63
+ class FetchedBaseRefs:
64
+ """`integration = "pr"`: work lands on the remote base, which the engine fetches.
65
+
66
+ Every root starts from the freshly fetched remote base, so local commits that were never
67
+ pushed are not in it.
68
+ """
69
+
70
+ def __init__(self, git: Git, project_root: Path, remote: str, *, fetch_timeout: float) -> None:
71
+ """Fetch from `remote` into Shikiru's own refs of the repository at `project_root`.
72
+
73
+ A fetch that takes longer than `fetch_timeout` seconds fails.
74
+ """
75
+ self._git = git
76
+ self._project_root = project_root
77
+ self._remote = remote
78
+ self._fetch_timeout = fetch_timeout
79
+
80
+ async def resolve(self, base: str) -> str:
81
+ """Fetch the remote `base` and return the ref it was fetched into.
82
+
83
+ Raises `BaseFetchFailed` when the fetch fails: a root is never built from a stale ref.
84
+ """
85
+ return await self._fetch(base)
86
+
87
+ async def sync(self, base: str, merge_commit: str | None) -> str:
88
+ """Fetch the remote `base` and return its tip, once it contains `merge_commit`.
89
+
90
+ The fetch updates Shikiru's ref and the remote-tracking ref of `base`; the local branch
91
+ is advanced separately by `LocalBaseSync`. Raises `BaseFetchFailed` when the fetch fails
92
+ and `MergedBaseUnavailable` when the merge commit is missing or not on the base.
93
+ """
94
+ ref = await self._fetch(base)
95
+ tip = await self._git.rev_parse(self._project_root, ref)
96
+ if merge_commit is None or not await self._git.merge_base_is_ancestor(
97
+ self._project_root, merge_commit, tip
98
+ ):
99
+ raise MergedBaseUnavailable(base, merge_commit)
100
+ return tip
101
+
102
+ async def _fetch(self, base: str) -> str:
103
+ ref = f"{BASE_REF_PREFIX}{base}"
104
+ tracking_ref = f"{_REMOTE_TRACKING_PREFIX}{self._remote}/{base}"
105
+ try:
106
+ await self._git.fetch(
107
+ self._project_root,
108
+ self._remote,
109
+ f"+{_HEADS_PREFIX}{base}:{ref}",
110
+ f"+{_HEADS_PREFIX}{base}:{tracking_ref}",
111
+ timeout=self._fetch_timeout,
112
+ )
113
+ except (CommandFailed, CommandTimeoutError) as error:
114
+ raise BaseFetchFailed(base, self._remote, error) from error
115
+ return ref
116
+
117
+
118
+ class LocalBaseSync:
119
+ """Moves the local base branch to the verified remote tip, and only fast-forwards it.
120
+
121
+ A checked-out base advances in its own worktree, so git refuses any merge that would
122
+ overwrite uncommitted work. A base that is not checked out moves by compare-and-swap, so a
123
+ concurrent change makes the update fail. A local base holding commits the remote tip lacks
124
+ is never moved.
125
+ """
126
+
127
+ def __init__(self, git: Git, project_root: Path) -> None:
128
+ """Advance the branches of the repository at `project_root` through `git`."""
129
+ self._git = git
130
+ self._project_root = project_root
131
+ self._lock = asyncio.Lock()
132
+
133
+ async def advance(self, base: str, tip: str) -> None:
134
+ """Point the local `base` at `tip`, or raise `LocalBaseBlocked` when that is not safe.
135
+
136
+ The move is verified: a base that does not reach `tip` raises rather than reporting
137
+ success. Idempotent: a base already at `tip` is left alone, so retries and restarts
138
+ are safe.
139
+ """
140
+ async with self._lock:
141
+ current = await self._git.local_branch_sha(self._project_root, base)
142
+ if current == tip:
143
+ return
144
+ if current is not None and not await self._git.merge_base_is_ancestor(
145
+ self._project_root, current, tip
146
+ ):
147
+ raise LocalBaseBlocked(base, tip, _DIVERGED_REASON)
148
+ try:
149
+ await self._move(base, tip, current)
150
+ except (CommandFailed, CommandTimeoutError) as error:
151
+ raise LocalBaseBlocked(base, tip, str(error)) from error
152
+ if await self._git.local_branch_sha(self._project_root, base) != tip:
153
+ raise LocalBaseBlocked(base, tip, _NOT_MOVED_REASON)
154
+
155
+ async def _move(self, base: str, tip: str, current: str | None) -> None:
156
+ checkouts = await self._git.branch_checkouts(self._project_root, base)
157
+ if checkouts:
158
+ await self._git.fast_forward_checked_out(checkouts[0], base, tip)
159
+ return
160
+ await self._git.update_ref(self._project_root, base, tip, current or _MISSING_REF_SHA)
@@ -0,0 +1,136 @@
1
+ """Copy-on-write cache cloning into a task worktree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import platform
7
+ import shutil
8
+ import tempfile
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+ from uuid import uuid4
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ from shikiru.process import SubprocessRunner
18
+
19
+ _STAGING_SUFFIX = ".cache-staging"
20
+ _PROBE_PREFIX = ".shikiru-cow-probe-"
21
+ _PROBE_DIRECTORY_PREFIX = ".shikiru-cow-check-"
22
+ _PROBE_CONTENT = b"shikiru cow probe"
23
+
24
+ CACHE_COW_UNSUPPORTED_WARNING = (
25
+ "copy-on-write copy is not supported on this filesystem; caches were not cloned"
26
+ )
27
+
28
+ _MACOS_COW_ARGS = ("cp", "-c", "-R")
29
+ _LINUX_COW_ARGS = ("cp", "-a", "--reflink=always")
30
+ _COW_ARGS_BY_SYSTEM = {"Darwin": _MACOS_COW_ARGS, "Linux": _LINUX_COW_ARGS}
31
+
32
+
33
+ def default_cow_args() -> tuple[str, ...] | None:
34
+ """Return this platform's copy-on-write `cp` invocation, or `None` if none is known."""
35
+ return _COW_ARGS_BY_SYSTEM.get(platform.system())
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class CacheCloneResult:
40
+ """The outcome of one `CacheCloner.clone` call."""
41
+
42
+ copied: tuple[Path, ...] = field(default_factory=tuple)
43
+ warning: str | None = None
44
+
45
+
46
+ async def _remove_tree(path: Path) -> None:
47
+ if path.exists():
48
+ await asyncio.to_thread(shutil.rmtree, path)
49
+
50
+
51
+ class CacheCloner:
52
+ """Clone `repo.caches` globs that are git-ignored, copy-on-write, into a worktree."""
53
+
54
+ def __init__(
55
+ self, runner: SubprocessRunner, *, cow_args: Sequence[str] | None, copy_timeout: float
56
+ ) -> None:
57
+ """Copy with `cow_args` (the platform's copy-on-write `cp` invocation), via `runner`.
58
+
59
+ One copy that takes longer than `copy_timeout` seconds fails.
60
+
61
+ `cow_args` is `None` when this platform has no known copy-on-write `cp` invocation
62
+ (see `default_cow_args`); cloning then reports the filesystem as unsupported outright.
63
+ """
64
+ self._runner = runner
65
+ self._cow_args = tuple(cow_args) if cow_args is not None else None
66
+ self._copy_timeout = copy_timeout
67
+
68
+ async def clone(
69
+ self,
70
+ repo_dir: Path,
71
+ worktree_dir: Path,
72
+ cache_globs: Sequence[str],
73
+ ignored: Sequence[Path],
74
+ ) -> CacheCloneResult:
75
+ """Copy every ignored path in `repo_dir` matching `cache_globs` into `worktree_dir`.
76
+
77
+ Only paths that exist and are in `ignored` are copied. If this filesystem does not
78
+ support copy-on-write, nothing is copied and exactly one warning is returned. Each copy
79
+ is made beside the worktree and moved into place only once complete, so a destination
80
+ that exists is always whole; a failed, timed-out or cancelled copy leaves nothing behind.
81
+ """
82
+ cow_args = self._cow_args
83
+ if cow_args is None or not await self._cow_supported(cow_args, worktree_dir):
84
+ return CacheCloneResult(warning=CACHE_COW_UNSUPPORTED_WARNING)
85
+ ignored_set = set(ignored)
86
+ copied: list[Path] = []
87
+ staging = worktree_dir.with_name(f"{worktree_dir.name}{_STAGING_SUFFIX}")
88
+ await _remove_tree(staging)
89
+ try:
90
+ for pattern in cache_globs:
91
+ for match in sorted(repo_dir.glob(pattern)):
92
+ if match not in ignored_set or not match.exists():
93
+ continue
94
+ relative = match.relative_to(repo_dir)
95
+ destination = worktree_dir / relative
96
+ if destination.exists():
97
+ continue
98
+ staged = staging / relative
99
+ staged.parent.mkdir(parents=True, exist_ok=True)
100
+ await self._copy(cow_args, match, staged)
101
+ destination.parent.mkdir(parents=True, exist_ok=True)
102
+ staged.replace(destination)
103
+ copied.append(relative)
104
+ finally:
105
+ await _remove_tree(staging)
106
+ return CacheCloneResult(copied=tuple(copied))
107
+
108
+ async def check_support(self, filesystem_root: Path) -> bool:
109
+ """Check copy-on-write using disposable probe files on `filesystem_root`."""
110
+ if self._cow_args is None:
111
+ return False
112
+ with tempfile.TemporaryDirectory(
113
+ prefix=_PROBE_DIRECTORY_PREFIX, dir=filesystem_root
114
+ ) as directory:
115
+ return await self._cow_supported(self._cow_args, Path(directory))
116
+
117
+ async def _cow_supported(self, cow_args: tuple[str, ...], worktree_dir: Path) -> bool:
118
+ probe_source = worktree_dir / f"{_PROBE_PREFIX}{uuid4().hex}"
119
+ probe_destination = worktree_dir / f"{_PROBE_PREFIX}{uuid4().hex}-copy"
120
+ probe_source.write_bytes(_PROBE_CONTENT)
121
+ try:
122
+ result = await self._runner.run(
123
+ [*cow_args, str(probe_source), str(probe_destination)],
124
+ timeout=self._copy_timeout,
125
+ )
126
+ finally:
127
+ probe_source.unlink(missing_ok=True)
128
+ probe_destination.unlink(missing_ok=True)
129
+ return result.returncode == 0
130
+
131
+ async def _copy(self, cow_args: tuple[str, ...], source: Path, destination: Path) -> None:
132
+ await self._runner.run(
133
+ [*cow_args, str(source), str(destination)],
134
+ timeout=self._copy_timeout,
135
+ check=True,
136
+ )
@@ -0,0 +1,118 @@
1
+ """Git facts for `facts.md`, outranking the Implementer's claims."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.domain.briefing import (
9
+ FACTS_COMMITS_HEADING,
10
+ FACTS_DIFF_STAT_HEADING,
11
+ FACTS_NONE_MARKER,
12
+ )
13
+ from shikiru.domain.results import LiveWork
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+
19
+ from shikiru.domain.tasks import RepoState
20
+ from shikiru.workspace.git import Git
21
+
22
+ _HEAD_REF = "HEAD"
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class WorkspaceFacts:
27
+ """The facts computed for a task's worktree."""
28
+
29
+ head_branch: str | None = None
30
+ commits: tuple[str, ...] = field(default_factory=tuple)
31
+ diff_stat: str = ""
32
+ status: tuple[str, ...] = field(default_factory=tuple)
33
+ changed_trackers: tuple[str, ...] = field(default_factory=tuple)
34
+
35
+
36
+ DETACHED_HEAD = "a detached HEAD"
37
+ OFF_BRANCH_ISSUE = (
38
+ "The workspace is on {actual}, not its task branch {branch}, so its commits "
39
+ "({commits}) would never be integrated. Bring that work onto {branch}, switch back to it "
40
+ "and stay on it."
41
+ )
42
+
43
+
44
+ def describe_off_branch(branch: str, head_branch: str | None, commits: Sequence[str]) -> str:
45
+ """Describe a worktree whose `HEAD` is not on `branch`, naming the commits at risk."""
46
+ return OFF_BRANCH_ISSUE.format(
47
+ actual=head_branch or DETACHED_HEAD,
48
+ branch=branch,
49
+ commits="; ".join(commits) or "none ahead of the base",
50
+ )
51
+
52
+
53
+ def render_facts(facts: WorkspaceFacts) -> str:
54
+ """Render `facts` as the markdown text a `verify`/`conflicts`/`handoff` prompt embeds.
55
+
56
+ These facts outrank the Implementer's claims, so they carry no interpretation, only the
57
+ raw `git` output `GitFacts.collect` gathered.
58
+ """
59
+ lines = [FACTS_COMMITS_HEADING]
60
+ lines.append("\n".join(facts.commits) if facts.commits else FACTS_NONE_MARKER)
61
+ lines.append(FACTS_DIFF_STAT_HEADING)
62
+ lines.append(facts.diff_stat or FACTS_NONE_MARKER)
63
+ lines.append("### git status --porcelain")
64
+ lines.append("\n".join(facts.status) if facts.status else "(clean)")
65
+ lines.append("### Changed trackers")
66
+ lines.append(", ".join(facts.changed_trackers) if facts.changed_trackers else "(none)")
67
+ return "\n\n".join(lines)
68
+
69
+
70
+ class GitFacts:
71
+ """Computes `WorkspaceFacts` for a task's worktree, from real git state."""
72
+
73
+ def __init__(self, git: Git) -> None:
74
+ """Read git state through `git`."""
75
+ self._git = git
76
+
77
+ async def collect(
78
+ self, task_root: Path, repo: RepoState, trackers: Sequence[str]
79
+ ) -> WorkspaceFacts:
80
+ """Collect facts for the worktree at `task_root`, checked out as `repo`."""
81
+ head_branch = await self._git.head_branch(task_root)
82
+ diff_range = f"{repo.base_ref}...HEAD"
83
+ commits = await self._git.log_oneline(task_root, f"{repo.base_ref}..HEAD")
84
+ diff_stat = await self._git.diff_stat(task_root, diff_range)
85
+ status = await self._git.status_porcelain(task_root)
86
+ changed_paths = set(await self._git.diff_name_only(task_root, diff_range))
87
+ return WorkspaceFacts(
88
+ head_branch=head_branch,
89
+ commits=tuple(commits),
90
+ diff_stat=diff_stat,
91
+ status=tuple(status),
92
+ changed_trackers=tuple(tracker for tracker in trackers if tracker in changed_paths),
93
+ )
94
+
95
+
96
+ class TaskWorkObserver:
97
+ """Reads a task's live work from its worktree, as it is now rather than as recorded."""
98
+
99
+ def __init__(self, git: Git) -> None:
100
+ """Read git state through `git`."""
101
+ self._git = git
102
+
103
+ async def observe(self, task_root: Path, base_ref: str) -> LiveWork:
104
+ """Return the commits past `base_ref` and the changes of `task_root` against their base.
105
+
106
+ Tracked edits, staged or not, are counted once against the merge base of `base_ref` and
107
+ `HEAD`, so committed work is never counted twice. Untracked and binary files count as
108
+ files, but contribute no line counts, because git gives none for them.
109
+ """
110
+ merge_base = await self._git.merge_base(task_root, base_ref, _HEAD_REF)
111
+ commits = await self._git.log_oneline(task_root, f"{base_ref}..{_HEAD_REF}")
112
+ changes = await self._git.working_tree_line_changes(task_root, against=merge_base)
113
+ return LiveWork.from_oneline(
114
+ commits,
115
+ files=changes.changed_paths,
116
+ insertions=changes.added_lines,
117
+ deletions=changes.deleted_lines,
118
+ )