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,577 @@
1
+ """Run one PR feedback round: analyse, rework, verify, publish and reply, resumably."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.events import EventKind
8
+ from shikiru.domain.feedback import (
9
+ FeedbackInput,
10
+ FeedbackSourceKind,
11
+ FeedbackStep,
12
+ next_feedback_step,
13
+ operator_questions,
14
+ publishable_replies,
15
+ )
16
+ from shikiru.domain.feedback_rounds import (
17
+ SHORT_SHA_LENGTH,
18
+ Alignment,
19
+ FeedbackReply,
20
+ FeedbackRound,
21
+ FeedbackStage,
22
+ PublicationAction,
23
+ ReplyState,
24
+ align_with_remote,
25
+ describe_checks,
26
+ fixed_source_ids,
27
+ plan_publication,
28
+ reply_body,
29
+ reply_marker,
30
+ )
31
+ from shikiru.domain.reports import (
32
+ DECISION_ACCEPT,
33
+ DECISION_CHANGES_REQUESTED,
34
+ DECISION_ESCALATE,
35
+ STATUS_COMPLETED,
36
+ FeedbackAnalysis,
37
+ FeedbackRework,
38
+ FeedbackVerdict,
39
+ ReportKind,
40
+ )
41
+ from shikiru.domain.tasks import Role
42
+ from shikiru.engine.integration import (
43
+ PUBLISHED_BASE_KEY,
44
+ PUBLISHED_TIP_KEY,
45
+ IntegrationBlocked,
46
+ IntegrationHeld,
47
+ PullRequestClosed,
48
+ )
49
+ from shikiru.errors import InvariantError, ShikiruError
50
+ from shikiru.paths import task_root
51
+ from shikiru.prompts.renderer import PromptName
52
+ from shikiru.workspace.github import PullRequestState
53
+
54
+ if TYPE_CHECKING:
55
+ from collections.abc import Awaitable, Callable
56
+ from pathlib import Path
57
+
58
+ from shikiru.config.settings import OrchestrationConfig
59
+ from shikiru.domain.tasks import Task
60
+ from shikiru.engine.step_runner import StepRunner
61
+ from shikiru.store.events import EventLog
62
+ from shikiru.store.feedback import FeedbackStore
63
+ from shikiru.store.tasks import TaskStore
64
+ from shikiru.workspace.git import Git
65
+ from shikiru.workspace.github import GitHub, PullRequestComment, ReviewThread
66
+
67
+ type PreparePublication = Callable[[Task], Awaitable[Task]]
68
+
69
+ _SOURCE_LINK = "{id}: {url}"
70
+ _SOURCE_LINK_SEPARATOR = "; "
71
+ _QUESTION_SEPARATOR = "\n"
72
+ _EXHAUSTED = "feedback on {url} is not resolved after {attempts} implementation attempts ({links})"
73
+ _REJECTED = "the feedback changes on {url} were rejected: {issues} ({links})"
74
+ _ISSUE_SEPARATOR = "; "
75
+ _IMPLEMENTER_STOPPED = "the feedback rework on {url} stopped: {question} ({links})"
76
+ _NEEDS_OPERATOR = "feedback on {url} needs the Operator: {questions} ({links})"
77
+ _QUESTION_ITEM = "{id}: {question}"
78
+ _ESCALATED_BY_LEADER = "the Leader escalated feedback on {url}: {question} ({links})"
79
+ _DIVERGED = (
80
+ "the remote branch {branch} of {url} holds commits the task branch does not "
81
+ "(remote {remote_sha}, task {local_sha}); both sides are kept"
82
+ )
83
+ _REMOTE_MOVED = (
84
+ "the remote branch {branch} of {url} moved to {remote_sha} while the round expected "
85
+ "{expected}; both sides are kept"
86
+ )
87
+ _PUSH_FAILED = "publishing {branch} for {url} failed: {error}"
88
+ _REPLY_FAILED = "replying to {source} on {url} failed: {error}"
89
+ _ALIGN_FAILED = "reading the remote branch {branch} of {url} failed: {error}"
90
+ _THREAD_MISSING = "the discussion {source} is no longer on {url}; its reply cannot be posted"
91
+ _NOTHING_VERIFIED = "{task} round {round} has no verified tip to publish"
92
+ _ROUND_VANISHED = "{task} lost its feedback round while it ran"
93
+ _NO_ANALYSIS = "{task} round {round} reached {stage} without an analysis"
94
+ _REMOTE_REF = "+refs/heads/{branch}:refs/remotes/{remote}/{branch}"
95
+
96
+
97
+ class FeedbackBlocked(IntegrationBlocked):
98
+ """A feedback round cannot go on without the Operator."""
99
+
100
+
101
+ class FeedbackSuperseded(Exception):
102
+ """The pull request was merged, so its feedback needs no more work."""
103
+
104
+
105
+ class FeedbackRounds:
106
+ """Run the persisted feedback round of a task's pull request.
107
+
108
+ The Leader analyses and verifies, the Implementer makes the accepted changes, and this
109
+ class publishes the verified commits and sends the verified replies itself.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ *,
115
+ store: FeedbackStore,
116
+ steps: StepRunner,
117
+ github: GitHub,
118
+ git: Git,
119
+ tasks: TaskStore,
120
+ events: EventLog,
121
+ project_root: Path,
122
+ remote: str,
123
+ settings: OrchestrationConfig,
124
+ ) -> None:
125
+ """Run turns through `steps`, publish to `remote` of `project_root`, and reply via `github`.
126
+
127
+ `settings.max_iterations` bounds the implementation attempts of one round.
128
+ """
129
+ self._store = store
130
+ self._steps = steps
131
+ self._github = github
132
+ self._git = git
133
+ self._tasks = tasks
134
+ self._events = events
135
+ self._project_root = project_root
136
+ self._remote = remote
137
+ self._settings = settings
138
+
139
+ def pending(self, task_id: str) -> bool:
140
+ """Return whether `task_id` has an unfinished feedback round."""
141
+ return self._store.active(task_id) is not None
142
+
143
+ async def run(self, task: Task, prepare: PreparePublication) -> None:
144
+ """Carry `task`'s unfinished round as far as it goes.
145
+
146
+ A round that waited for the Operator resumes where it stopped with a fresh attempt
147
+ budget. `prepare` rebases the branch before publication and returns the current task.
148
+ Raises `FeedbackBlocked` when the Operator must decide, `PullRequestClosed` when the
149
+ pull request closed unmerged, and `IntegrationHeld` when the task is held.
150
+ """
151
+ active = self._store.active(task.id)
152
+ if active is None:
153
+ return
154
+ if active.blocked:
155
+ self._store.reset_after_retry(active.id, active.stage)
156
+ try:
157
+ await self._advance(task, prepare)
158
+ except FeedbackSuperseded:
159
+ return
160
+
161
+ async def _advance(self, task: Task, prepare: PreparePublication) -> None:
162
+ current = task
163
+ while (active := self._store.active(task.id)) is not None:
164
+ self._require_not_held(task.id)
165
+ await self._require_open(active)
166
+ match active.stage:
167
+ case FeedbackStage.ANALYSIS:
168
+ current = await self._analyse(current, active)
169
+ case FeedbackStage.IMPLEMENTATION:
170
+ current = await self._implement(current, active)
171
+ case FeedbackStage.VERIFICATION:
172
+ current = await self._verify(current, active)
173
+ case FeedbackStage.PUBLICATION:
174
+ current = await self._publish(current, active, prepare)
175
+ case FeedbackStage.REPLIES:
176
+ await self._reply(active)
177
+ case _:
178
+ message = f"{task.id} round {active.id} is unfinished at {active.stage}"
179
+ raise InvariantError(message)
180
+
181
+ async def _analyse(self, task: Task, active: FeedbackRound) -> Task:
182
+ current, outcome = await self._steps.run_report_turn(
183
+ task,
184
+ role=Role.LEADER,
185
+ template=PromptName.FEEDBACK_ANALYSIS,
186
+ kind=ReportKind.FEEDBACK_ANALYSIS,
187
+ feedback=FeedbackInput(snapshot=active.snapshot),
188
+ )
189
+ if not isinstance(outcome, FeedbackAnalysis):
190
+ message = f"{task.id} feedback analysis produced no analysis"
191
+ raise InvariantError(message)
192
+ match next_feedback_step(outcome):
193
+ case FeedbackStep.ESCALATE:
194
+ self._store.save_analysis(active.id, outcome, FeedbackStage.ANALYSIS)
195
+ questions = _QUESTION_SEPARATOR.join(
196
+ _QUESTION_ITEM.format(id=source_id, question=question)
197
+ for source_id, question in operator_questions(outcome)
198
+ )
199
+ raise self._blocked(
200
+ active,
201
+ _NEEDS_OPERATOR.format(
202
+ url=active.pull_request_url,
203
+ questions=questions,
204
+ links=self._source_links(active),
205
+ ),
206
+ )
207
+ case FeedbackStep.IMPLEMENT:
208
+ await self._align_with_remote(current, active)
209
+ self._store.save_analysis(active.id, outcome, FeedbackStage.IMPLEMENTATION)
210
+ case FeedbackStep.VERIFY:
211
+ self._store.save_analysis(active.id, outcome, FeedbackStage.VERIFICATION)
212
+ return current
213
+
214
+ async def _implement(self, task: Task, active: FeedbackRound) -> Task:
215
+ analysis = self._analysis_of(active)
216
+ limit = self._settings.max_iterations
217
+ if active.attempts >= limit:
218
+ raise self._blocked(
219
+ active,
220
+ _EXHAUSTED.format(
221
+ url=active.pull_request_url,
222
+ attempts=active.attempts,
223
+ links=self._source_links(active),
224
+ ),
225
+ )
226
+ current, outcome = await self._steps.run_report_turn(
227
+ task,
228
+ role=Role.IMPLEMENTER,
229
+ template=PromptName.FEEDBACK_REWORK,
230
+ kind=ReportKind.FEEDBACK_REWORK,
231
+ feedback=FeedbackInput(snapshot=active.snapshot, analysis=analysis),
232
+ )
233
+ if not isinstance(outcome, FeedbackRework):
234
+ message = f"{task.id} feedback rework produced no rework report"
235
+ raise InvariantError(message)
236
+ if outcome.status != STATUS_COMPLETED:
237
+ raise self._blocked(
238
+ active,
239
+ _IMPLEMENTER_STOPPED.format(
240
+ url=active.pull_request_url,
241
+ question=outcome.question,
242
+ links=self._source_links(active),
243
+ ),
244
+ )
245
+ self._store.complete_implementation(
246
+ active.id, active.attempts + 1, describe_checks(outcome.tests)
247
+ )
248
+ return current
249
+
250
+ async def _verify(self, task: Task, active: FeedbackRound) -> Task:
251
+ analysis = self._analysis_of(active)
252
+ final_round = active.attempts >= self._settings.max_iterations
253
+ tip = await self._git.rev_parse(task_root(self._project_root, task.id), self._branch(task))
254
+ current, outcome = await self._steps.run_report_turn(
255
+ task,
256
+ role=Role.LEADER,
257
+ template=PromptName.FEEDBACK_VERIFY,
258
+ kind=ReportKind.FEEDBACK_VERDICT,
259
+ feedback=FeedbackInput(
260
+ snapshot=active.snapshot, analysis=analysis, final_round=final_round, tip=tip
261
+ ),
262
+ )
263
+ if not isinstance(outcome, FeedbackVerdict):
264
+ message = f"{task.id} feedback verification produced no verdict"
265
+ raise InvariantError(message)
266
+ links = self._source_links(active)
267
+ if outcome.decision == DECISION_ACCEPT:
268
+ replies = publishable_replies(active.snapshot, analysis, outcome)
269
+ if analysis.work:
270
+ self._store.save_verified(
271
+ active.id, sha=tip, replies=replies, stage=FeedbackStage.PUBLICATION
272
+ )
273
+ else:
274
+ self._store.save_verified(
275
+ active.id,
276
+ sha=tip,
277
+ replies=replies,
278
+ stage=FeedbackStage.REPLIES,
279
+ planned=self._planned_replies(active, replies, None),
280
+ )
281
+ elif outcome.decision == DECISION_CHANGES_REQUESTED and analysis.work:
282
+ self._store.set_stage(active.id, FeedbackStage.IMPLEMENTATION)
283
+ elif outcome.decision == DECISION_ESCALATE and outcome.escalation is not None:
284
+ raise self._blocked(
285
+ active,
286
+ _ESCALATED_BY_LEADER.format(
287
+ url=active.pull_request_url, question=outcome.escalation.question, links=links
288
+ ),
289
+ )
290
+ else:
291
+ issues = _ISSUE_SEPARATOR.join(issue.description for issue in outcome.issues)
292
+ raise self._blocked(
293
+ active,
294
+ _REJECTED.format(
295
+ url=active.pull_request_url, issues=issues or outcome.summary, links=links
296
+ ),
297
+ )
298
+ return current
299
+
300
+ async def _publish(
301
+ self, task: Task, active: FeedbackRound, prepare: PreparePublication
302
+ ) -> Task:
303
+ verified = active.verified_sha
304
+ if verified is None:
305
+ message = _NOTHING_VERIFIED.format(task=task.id, round=active.id)
306
+ raise InvariantError(message)
307
+ task_dir = task_root(self._project_root, task.id)
308
+ branch = self._branch(task)
309
+ remote_head = await self._observed_head(active)
310
+ local_tip = await self._git.rev_parse(task_dir, branch)
311
+ if remote_head == verified and local_tip == verified:
312
+ await self._published(task, active, verified)
313
+ return task
314
+ current = await prepare(task)
315
+ tip = await self._git.rev_parse(task_dir, branch)
316
+ if tip != verified:
317
+ self._store.set_stage(active.id, FeedbackStage.VERIFICATION)
318
+ return current
319
+ self._require_not_held(task.id)
320
+ remote_head = await self._observed_head(active)
321
+ match plan_publication(
322
+ expected=active.expected_remote_sha, remote_head=remote_head, local_tip=tip
323
+ ):
324
+ case PublicationAction.REMOTE_MOVED:
325
+ raise self._blocked(
326
+ active,
327
+ _REMOTE_MOVED.format(
328
+ branch=branch,
329
+ url=active.pull_request_url,
330
+ remote_sha=remote_head,
331
+ expected=active.expected_remote_sha,
332
+ ),
333
+ )
334
+ case PublicationAction.PUSH:
335
+ await self._prepared(task, active, tip)
336
+ await self._push(active, branch, active.expected_remote_sha)
337
+ case PublicationAction.ALREADY_PUBLISHED:
338
+ pass
339
+ await self._published(task, active, tip)
340
+ return current
341
+
342
+ async def _push(self, active: FeedbackRound, branch: str, lease: str) -> None:
343
+ try:
344
+ await self._git.push(self._project_root, self._remote, branch, lease=lease)
345
+ except ShikiruError as error:
346
+ raise self._blocked(
347
+ active,
348
+ _PUSH_FAILED.format(branch=branch, url=active.pull_request_url, error=error),
349
+ ) from error
350
+
351
+ async def _prepared(self, task: Task, active: FeedbackRound, sha: str) -> None:
352
+ base_sha = await self._git.merge_base(
353
+ task_root(self._project_root, task.id), self._base_ref(task), sha
354
+ )
355
+ self._events.append(
356
+ EventKind.FEEDBACK_PREPARED,
357
+ task_id=active.task_id,
358
+ message=f"{active.task_id} prepared {sha[:SHORT_SHA_LENGTH]} for the feedback",
359
+ data={
360
+ "url": active.pull_request_url,
361
+ "round": active.id,
362
+ PUBLISHED_TIP_KEY: sha,
363
+ PUBLISHED_BASE_KEY: base_sha,
364
+ },
365
+ )
366
+
367
+ async def _published(self, task: Task, active: FeedbackRound, sha: str) -> None:
368
+ base_sha = await self._git.merge_base(
369
+ task_root(self._project_root, task.id), self._base_ref(task), sha
370
+ )
371
+ self._store.save_published(active.id, sha)
372
+ self._events.append(
373
+ EventKind.FEEDBACK_PUBLISHED,
374
+ task_id=active.task_id,
375
+ message=f"{active.task_id} published {sha[:SHORT_SHA_LENGTH]} for the feedback",
376
+ data={
377
+ "url": active.pull_request_url,
378
+ "round": active.id,
379
+ PUBLISHED_TIP_KEY: sha,
380
+ PUBLISHED_BASE_KEY: base_sha,
381
+ },
382
+ )
383
+ self._store.plan_replies(
384
+ active.id, self._planned_replies(active, active.verified_replies, sha)
385
+ )
386
+
387
+ def _planned_replies(
388
+ self, active: FeedbackRound, replies: dict[str, str], fixed_sha: str | None
389
+ ) -> list[FeedbackReply]:
390
+ analysis = self._analysis_of(active)
391
+ fixed: set[str] = fixed_source_ids(analysis) if fixed_sha is not None else set()
392
+ planned: list[FeedbackReply] = []
393
+ for source in active.snapshot.sources:
394
+ reply = replies.get(source.id)
395
+ if reply is None:
396
+ continue
397
+ marker = reply_marker(active.task_id, active.id, source.id)
398
+ body = reply_body(
399
+ reply=reply,
400
+ marker=marker,
401
+ review_url=source.url if source.kind is FeedbackSourceKind.REVIEW_BODY else None,
402
+ fixed_sha=fixed_sha if source.id in fixed else None,
403
+ checks=active.checks,
404
+ )
405
+ planned.append(
406
+ FeedbackReply(
407
+ round_id=active.id,
408
+ source_id=source.id,
409
+ kind=source.kind,
410
+ marker=marker,
411
+ body=body,
412
+ )
413
+ )
414
+ return planned
415
+
416
+ async def _reply(self, active: FeedbackRound) -> None:
417
+ for reply in self._store.replies(active.id):
418
+ if reply.state is ReplyState.SENT:
419
+ continue
420
+ self._require_not_held(active.task_id)
421
+ await self._require_open(active)
422
+ await self._send(active, reply)
423
+ self._store.set_stage(active.id, FeedbackStage.HANDLED)
424
+ self._events.append(
425
+ EventKind.FEEDBACK_HANDLED,
426
+ task_id=active.task_id,
427
+ message=f"{active.task_id} answered the feedback on {active.pull_request_url}",
428
+ data={"url": active.pull_request_url, "round": active.id},
429
+ )
430
+
431
+ async def _send(self, active: FeedbackRound, reply: FeedbackReply) -> None:
432
+ url = active.pull_request_url
433
+ try:
434
+ thread = await self._thread_of(active, reply)
435
+ if reply.state is ReplyState.SENDING:
436
+ found = await self._find(url, reply, thread)
437
+ if found is not None:
438
+ self._store.set_reply_state(
439
+ active.id, reply.source_id, ReplyState.SENT, found.url
440
+ )
441
+ return
442
+ self._require_not_held(active.task_id)
443
+ await self._require_open(active)
444
+ self._store.set_reply_state(active.id, reply.source_id, ReplyState.SENDING)
445
+ posted = await self._post(url, reply, thread)
446
+ except PullRequestClosed, FeedbackBlocked:
447
+ raise
448
+ except ShikiruError as error:
449
+ raise self._blocked(
450
+ active,
451
+ _REPLY_FAILED.format(source=reply.source_id, url=url, error=error),
452
+ ) from error
453
+ self._store.set_reply_state(active.id, reply.source_id, ReplyState.SENT, posted.url)
454
+
455
+ async def _thread_of(self, active: FeedbackRound, reply: FeedbackReply) -> ReviewThread | None:
456
+ if reply.kind is not FeedbackSourceKind.DISCUSSION:
457
+ return None
458
+ feedback = await self._github.pull_request_feedback(
459
+ self._project_root, active.pull_request_url
460
+ )
461
+ self._end_unless_open(active, feedback.state)
462
+ for thread in feedback.threads:
463
+ if thread.id == reply.source_id:
464
+ return thread
465
+ raise self._blocked(
466
+ active,
467
+ _THREAD_MISSING.format(source=reply.source_id, url=active.pull_request_url),
468
+ )
469
+
470
+ async def _find(
471
+ self, url: str, reply: FeedbackReply, thread: ReviewThread | None
472
+ ) -> PullRequestComment | None:
473
+ if thread is not None:
474
+ return await self._github.find_thread_reply(
475
+ self._project_root, url, thread=thread, marker=reply.marker
476
+ )
477
+ return await self._github.find_conversation_comment(
478
+ self._project_root, url, marker=reply.marker
479
+ )
480
+
481
+ async def _post(
482
+ self, url: str, reply: FeedbackReply, thread: ReviewThread | None
483
+ ) -> PullRequestComment:
484
+ if thread is not None:
485
+ return await self._github.reply_to_thread(
486
+ self._project_root, url, thread=thread, body=reply.body
487
+ )
488
+ return await self._github.post_conversation_comment(
489
+ self._project_root, url, body=reply.body
490
+ )
491
+
492
+ async def _align_with_remote(self, task: Task, active: FeedbackRound) -> None:
493
+ task_dir = task_root(self._project_root, task.id)
494
+ branch = self._branch(task)
495
+ expected = active.expected_remote_sha
496
+ try:
497
+ await self._git.fetch(
498
+ self._project_root,
499
+ self._remote,
500
+ _REMOTE_REF.format(branch=branch, remote=self._remote),
501
+ timeout=self._settings.network.fetch_timeout_seconds,
502
+ )
503
+ alignment = align_with_remote(
504
+ remote_in_local=await self._git.merge_base_is_ancestor(task_dir, expected, branch),
505
+ local_in_remote=await self._git.merge_base_is_ancestor(task_dir, branch, expected),
506
+ )
507
+ if alignment is Alignment.FAST_FORWARD:
508
+ await self._git.merge_ff_only(task_dir, expected)
509
+ except ShikiruError as error:
510
+ raise self._blocked(
511
+ active,
512
+ _ALIGN_FAILED.format(branch=branch, url=active.pull_request_url, error=error),
513
+ ) from error
514
+ if alignment is Alignment.DIVERGED:
515
+ raise self._blocked(
516
+ active,
517
+ _DIVERGED.format(
518
+ branch=branch,
519
+ url=active.pull_request_url,
520
+ remote_sha=expected,
521
+ local_sha=await self._git.rev_parse(task_dir, branch),
522
+ ),
523
+ )
524
+
525
+ async def _observed_head(self, active: FeedbackRound) -> str:
526
+ feedback = await self._github.pull_request_feedback(
527
+ self._project_root, active.pull_request_url
528
+ )
529
+ self._end_unless_open(active, feedback.state)
530
+ return feedback.head_sha
531
+
532
+ async def _require_open(self, active: FeedbackRound) -> None:
533
+ status = await self._github.pull_request_status(self._project_root, active.pull_request_url)
534
+ self._end_unless_open(active, status.state)
535
+
536
+ def _end_unless_open(self, active: FeedbackRound, state: PullRequestState) -> None:
537
+ if state is PullRequestState.OPEN:
538
+ return
539
+ self._store.set_stage(active.id, FeedbackStage.ABANDONED)
540
+ if state is PullRequestState.CLOSED:
541
+ raise PullRequestClosed(active.pull_request_url)
542
+ raise FeedbackSuperseded
543
+
544
+ def _require_not_held(self, task_id: str) -> None:
545
+ current = self._tasks.get(task_id)
546
+ if current is None:
547
+ message = _ROUND_VANISHED.format(task=task_id)
548
+ raise InvariantError(message)
549
+ if current.held:
550
+ raise IntegrationHeld
551
+
552
+ def _blocked(self, active: FeedbackRound, question: str) -> FeedbackBlocked:
553
+ self._store.set_blocked(active.id, blocked=True)
554
+ return FeedbackBlocked(question)
555
+
556
+ def _analysis_of(self, active: FeedbackRound) -> FeedbackAnalysis:
557
+ if active.analysis is None:
558
+ message = _NO_ANALYSIS.format(task=active.task_id, round=active.id, stage=active.stage)
559
+ raise InvariantError(message)
560
+ return active.analysis
561
+
562
+ def _source_links(self, active: FeedbackRound) -> str:
563
+ return _SOURCE_LINK_SEPARATOR.join(
564
+ _SOURCE_LINK.format(id=source.id, url=source.url) for source in active.snapshot.sources
565
+ )
566
+
567
+ def _base_ref(self, task: Task) -> str:
568
+ if task.repo is None:
569
+ message = f"{task.id} has no task branch"
570
+ raise InvariantError(message)
571
+ return task.repo.base_ref
572
+
573
+ def _branch(self, task: Task) -> str:
574
+ if task.repo is None:
575
+ message = f"{task.id} has no task branch"
576
+ raise InvariantError(message)
577
+ return task.repo.branch
@@ -0,0 +1,80 @@
1
+ """Decide whether a merged pull request's task branch holds feedback work nobody published."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.domain.events import EventKind
9
+ from shikiru.engine.integration import PUBLISHED_TIP_KEY
10
+ from shikiru.errors import ShikiruError
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+ from shikiru.domain.tasks import Task
16
+ from shikiru.store.events import EventLog
17
+ from shikiru.store.feedback import FeedbackStore
18
+ from shikiru.workspace.git import Git
19
+ from shikiru.workspace.github import GitHub
20
+
21
+ _OPENED_TIP_KEY = "branch_sha"
22
+ _BRANCH_REF = "refs/heads/{branch}"
23
+ _LOGGER = logging.getLogger(__name__)
24
+
25
+
26
+ class FeedbackWorkAudit:
27
+ """Tell, from recorded publication evidence, whether a task tip may be discarded."""
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ git: Git,
33
+ github: GitHub,
34
+ events: EventLog,
35
+ store: FeedbackStore,
36
+ project_root: Path,
37
+ ) -> None:
38
+ """Read the task branch of `project_root`, events, feedback rounds and the PR head."""
39
+ self._git = git
40
+ self._github = github
41
+ self._events = events
42
+ self._store = store
43
+ self._project_root = project_root
44
+
45
+ async def holds_unpublished_work(self, task: Task) -> bool:
46
+ """Return whether the branch of `task` may hold feedback commits the PR never received.
47
+
48
+ Only a task with a persisted feedback round can; the round row, not its event, is the
49
+ evidence that one started. Its current branch tip must be a
50
+ tip the engine recorded as pushed (when the PR opened or a round published), or the
51
+ head the pull request ended with. A merge style that breaks ancestry is therefore
52
+ irrelevant, and a push whose checkpoint was lost is settled by the PR head or else kept.
53
+ """
54
+ repo = task.repo
55
+ if repo is None:
56
+ return False
57
+ url = self._store.last_pull_request_url(task.id)
58
+ if url is None:
59
+ return False
60
+ recorded = self._events.since(
61
+ 0, kinds=[EventKind.PR_OPENED, EventKind.FEEDBACK_PUBLISHED], task_id=task.id
62
+ )
63
+ ref = _BRANCH_REF.format(branch=repo.branch)
64
+ if not await self._git.branch_exists(self._project_root, repo.branch):
65
+ return False
66
+ tip = await self._git.rev_parse(self._project_root, ref)
67
+ pushed = {
68
+ str(event.data[key])
69
+ for event in recorded
70
+ for key in (_OPENED_TIP_KEY, PUBLISHED_TIP_KEY)
71
+ if key in event.data
72
+ }
73
+ if tip in pushed:
74
+ return False
75
+ try:
76
+ feedback = await self._github.pull_request_feedback(self._project_root, url)
77
+ except ShikiruError:
78
+ _LOGGER.warning("keeping %s: the head of %s could not be read", repo.branch, url)
79
+ return True
80
+ return feedback.head_sha != tip