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,180 @@
1
+ """The frozen PR review feedback a Leader analyses, and the rules its reports must satisfy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum, auto
6
+ from typing import Annotated
7
+
8
+ from pydantic import Field, StringConstraints, model_validator
9
+
10
+ from shikiru.domain.reports import (
11
+ DECISION_ACCEPT,
12
+ FeedbackAction,
13
+ FeedbackAnalysis,
14
+ FeedbackVerdict,
15
+ Report,
16
+ )
17
+ from shikiru.domain.tasks import DomainModel
18
+
19
+ _Text = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
20
+
21
+
22
+ class FeedbackContractError(ValueError):
23
+ """A feedback report does not account for its snapshot or cannot be acted on."""
24
+
25
+
26
+ class FeedbackSourceKind(StrEnum):
27
+ """Where a source item of feedback was written."""
28
+
29
+ REVIEW_BODY = auto()
30
+ DISCUSSION = auto()
31
+
32
+
33
+ class FeedbackStep(StrEnum):
34
+ """What the engine may do next with a checked analysis."""
35
+
36
+ ESCALATE = auto()
37
+ IMPLEMENT = auto()
38
+ VERIFY = auto()
39
+
40
+
41
+ class FeedbackMessage(DomainModel):
42
+ """One comment of a source item, in the order it was written.
43
+
44
+ `new` marks a comment this round must answer; the others were handled by earlier rounds
45
+ or are our own replies.
46
+ """
47
+
48
+ author: str | None
49
+ body: str
50
+ url: str
51
+ new: bool = False
52
+
53
+
54
+ class FeedbackSource(DomainModel):
55
+ """A review body or an original inline discussion, with the replies that followed it."""
56
+
57
+ id: _Text
58
+ kind: FeedbackSourceKind
59
+ url: str
60
+ location: str = ""
61
+ outdated: bool = False
62
+ resolved: bool = False
63
+ messages: list[FeedbackMessage] = Field(min_length=1)
64
+
65
+
66
+ class FeedbackSnapshot(DomainModel):
67
+ """The feedback of one round, fixed when the round starts."""
68
+
69
+ pull_request_url: str
70
+ head_sha: str
71
+ sources: list[FeedbackSource] = Field(min_length=1)
72
+
73
+ @model_validator(mode="after")
74
+ def _source_ids_are_unique(self) -> FeedbackSnapshot:
75
+ ids = self.source_ids
76
+ if len(set(ids)) != len(ids):
77
+ message = "sources: duplicate ids"
78
+ raise ValueError(message)
79
+ return self
80
+
81
+ @property
82
+ def source_ids(self) -> list[str]:
83
+ """The identities of the sources, in snapshot order."""
84
+ return [source.id for source in self.sources]
85
+
86
+ def only(self, source_ids: set[str]) -> FeedbackSnapshot:
87
+ """Return the snapshot restricted to `source_ids`."""
88
+ return self.model_copy(
89
+ update={"sources": [source for source in self.sources if source.id in source_ids]}
90
+ )
91
+
92
+
93
+ class FeedbackInput(DomainModel):
94
+ """What a feedback turn is given: snapshot, analysis once made, round limit, tip verified."""
95
+
96
+ snapshot: FeedbackSnapshot
97
+ analysis: FeedbackAnalysis | None = None
98
+ final_round: bool = False
99
+ tip: str | None = None
100
+
101
+
102
+ def check_analysis(snapshot: FeedbackSnapshot, analysis: FeedbackAnalysis) -> None:
103
+ """Require one decision for each source of `snapshot` and no other."""
104
+ expected = set(snapshot.source_ids)
105
+ decided = {decision.source_id for decision in analysis.decisions}
106
+ unknown = decided - expected
107
+ if unknown:
108
+ message = f"decisions name unknown source ids {sorted(unknown)}"
109
+ raise FeedbackContractError(message)
110
+ missing = expected - decided
111
+ if missing:
112
+ message = f"decisions leave source ids {sorted(missing)} without a decision"
113
+ raise FeedbackContractError(message)
114
+
115
+
116
+ def next_feedback_step(analysis: FeedbackAnalysis) -> FeedbackStep:
117
+ """Choose the step after `analysis`: an Operator question first, then work, then replies.
118
+
119
+ A decision that needs the Operator is never read as approval to carry out the work of
120
+ the others, and a round with no accepted work needs no Implementer turn.
121
+ """
122
+ if operator_questions(analysis):
123
+ return FeedbackStep.ESCALATE
124
+ if analysis.work:
125
+ return FeedbackStep.IMPLEMENT
126
+ return FeedbackStep.VERIFY
127
+
128
+
129
+ def operator_questions(analysis: FeedbackAnalysis) -> list[tuple[str, str]]:
130
+ """Return each (source id, question) the Operator must answer."""
131
+ return [
132
+ (decision.source_id, decision.detail)
133
+ for decision in analysis.decisions
134
+ if decision.action is FeedbackAction.NEEDS_OPERATOR
135
+ ]
136
+
137
+
138
+ def check_verdict(
139
+ snapshot: FeedbackSnapshot, analysis: FeedbackAnalysis, verdict: FeedbackVerdict
140
+ ) -> None:
141
+ """Require an accepting verdict to verify exactly the replies the analysis drafted."""
142
+ check_analysis(snapshot, analysis)
143
+ if verdict.decision != DECISION_ACCEPT:
144
+ return
145
+ if operator_questions(analysis):
146
+ message = "decision: 'accept' is not available while a decision needs the Operator"
147
+ raise FeedbackContractError(message)
148
+ expected = {
149
+ decision.source_id
150
+ for decision in analysis.decisions
151
+ if decision.action is not FeedbackAction.NEEDS_OPERATOR
152
+ }
153
+ verified = {reply.source_id for reply in verdict.replies}
154
+ if verified != expected:
155
+ message = (
156
+ f"replies must cover exactly source ids {sorted(expected)}, got {sorted(verified)}"
157
+ )
158
+ raise FeedbackContractError(message)
159
+
160
+
161
+ def check_feedback_report(feedback: FeedbackInput, report: Report) -> None:
162
+ """Check `report` against the frozen `feedback` its turn was given."""
163
+ if isinstance(report, FeedbackAnalysis):
164
+ check_analysis(feedback.snapshot, report)
165
+ elif isinstance(report, FeedbackVerdict):
166
+ if feedback.analysis is None:
167
+ message = "a feedback verdict needs the analysis it verifies"
168
+ raise FeedbackContractError(message)
169
+ check_verdict(feedback.snapshot, feedback.analysis, report)
170
+
171
+
172
+ def publishable_replies(
173
+ snapshot: FeedbackSnapshot, analysis: FeedbackAnalysis, verdict: FeedbackVerdict
174
+ ) -> dict[str, str]:
175
+ """Return the verified reply of each source, or fail when the round is not accepted."""
176
+ if verdict.decision != DECISION_ACCEPT:
177
+ message = f"a '{verdict.decision}' verdict leaves no reply publishable"
178
+ raise FeedbackContractError(message)
179
+ check_verdict(snapshot, analysis, verdict)
180
+ return {reply.source_id: reply.reply for reply in verdict.replies}
@@ -0,0 +1,172 @@
1
+ """The durable progress of one PR feedback round and the pure rules that move it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum, auto
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pydantic import Field
9
+
10
+ from shikiru.domain.feedback import FeedbackSnapshot, FeedbackSourceKind
11
+ from shikiru.domain.reports import FeedbackAction, FeedbackAnalysis
12
+ from shikiru.domain.tasks import DomainModel
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ from shikiru.domain.reports import TestRun
18
+
19
+ MARKER_PREFIX = "shikiru-feedback"
20
+ MARKER_SEPARATOR = ":"
21
+ REPLY_SEPARATOR = "\n\n"
22
+ REVIEW_LINK_PREFIX = "In reply to"
23
+ FIXED_PREFIX = "Fixed in"
24
+ CHECKS_PREFIX = "Verified by"
25
+ CHECKS_SEPARATOR = "; "
26
+ SHORT_SHA_LENGTH = 12
27
+
28
+
29
+ class FeedbackStage(StrEnum):
30
+ """Where a feedback round stands; the first five stages are the unfinished ones."""
31
+
32
+ ANALYSIS = auto()
33
+ IMPLEMENTATION = auto()
34
+ VERIFICATION = auto()
35
+ PUBLICATION = auto()
36
+ REPLIES = auto()
37
+ HANDLED = auto()
38
+ ABANDONED = auto()
39
+
40
+
41
+ UNFINISHED_STAGES = frozenset(
42
+ (
43
+ FeedbackStage.ANALYSIS,
44
+ FeedbackStage.IMPLEMENTATION,
45
+ FeedbackStage.VERIFICATION,
46
+ FeedbackStage.PUBLICATION,
47
+ FeedbackStage.REPLIES,
48
+ )
49
+ )
50
+
51
+
52
+ class ReplyState(StrEnum):
53
+ """How far one reply got: stored, possibly sent (its outcome unknown), or confirmed sent."""
54
+
55
+ PLANNED = auto()
56
+ SENDING = auto()
57
+ SENT = auto()
58
+
59
+
60
+ class PublicationAction(StrEnum):
61
+ """What to do with the verified tip given where the remote branch is."""
62
+
63
+ PUSH = auto()
64
+ ALREADY_PUBLISHED = auto()
65
+ REMOTE_MOVED = auto()
66
+
67
+
68
+ class Alignment(StrEnum):
69
+ """How the task branch relates to the remote branch before a round edits it."""
70
+
71
+ ALIGNED = auto()
72
+ FAST_FORWARD = auto()
73
+ DIVERGED = auto()
74
+
75
+
76
+ class FeedbackRound(DomainModel):
77
+ """One batch of review feedback and everything done about it so far."""
78
+
79
+ id: int
80
+ task_id: str
81
+ pull_request_url: str
82
+ review_ids: list[str]
83
+ comment_ids: list[str] = Field(default_factory=list)
84
+ snapshot: FeedbackSnapshot
85
+ stage: FeedbackStage
86
+ analysis: FeedbackAnalysis | None = None
87
+ attempts: int = 0
88
+ blocked: bool = False
89
+ expected_remote_sha: str
90
+ verified_sha: str | None = None
91
+ published_sha: str | None = None
92
+ checks: str = ""
93
+ verified_replies: dict[str, str] = Field(default_factory=dict)
94
+
95
+
96
+ class FeedbackReply(DomainModel):
97
+ """The immutable reply to one source item, with the marker that finds it again."""
98
+
99
+ round_id: int
100
+ source_id: str
101
+ kind: FeedbackSourceKind
102
+ marker: str
103
+ body: str
104
+ state: ReplyState = ReplyState.PLANNED
105
+ posted_url: str | None = None
106
+
107
+
108
+ def reply_marker(task_id: str, round_id: int, source_id: str) -> str:
109
+ """Return the stable marker that identifies the reply to `source_id` in `round_id`."""
110
+ parts = (MARKER_PREFIX, task_id, str(round_id), source_id)
111
+ return f"<!-- {MARKER_SEPARATOR.join(parts)} -->"
112
+
113
+
114
+ def is_own_reply(body: str) -> bool:
115
+ """Return whether a comment `body` carries the marker of a reply this engine posted."""
116
+ return MARKER_PREFIX in body
117
+
118
+
119
+ def reply_body(
120
+ *,
121
+ reply: str,
122
+ marker: str,
123
+ review_url: str | None,
124
+ fixed_sha: str | None,
125
+ checks: str,
126
+ ) -> str:
127
+ """Compose a reply: the link of a review-body source, the verified text, the fix, the marker."""
128
+ parts: list[str] = []
129
+ if review_url is not None:
130
+ parts.append(f"{REVIEW_LINK_PREFIX} {review_url}")
131
+ parts.append(reply.strip())
132
+ if fixed_sha is not None:
133
+ fixed = f"{FIXED_PREFIX} {fixed_sha[:SHORT_SHA_LENGTH]}."
134
+ parts.append(f"{fixed} {CHECKS_PREFIX} {checks}." if checks else fixed)
135
+ parts.append(marker)
136
+ return REPLY_SEPARATOR.join(parts)
137
+
138
+
139
+ def describe_checks(tests: Sequence[TestRun]) -> str:
140
+ """Name the checks an Implementer ran and how each ended."""
141
+ return CHECKS_SEPARATOR.join(f"{test.name} {test.result}" for test in tests)
142
+
143
+
144
+ def fixed_source_ids(analysis: FeedbackAnalysis) -> set[str]:
145
+ """Return the sources whose decision changed code."""
146
+ return {
147
+ decision.source_id
148
+ for decision in analysis.decisions
149
+ if decision.action is FeedbackAction.ADDRESS
150
+ }
151
+
152
+
153
+ def plan_publication(*, expected: str, remote_head: str, local_tip: str) -> PublicationAction:
154
+ """Decide how to publish `local_tip` when the remote branch is at `remote_head`.
155
+
156
+ The remote only ever matches what the round captured (`expected`) or the tip itself; any
157
+ other head is somebody else's work that a push must not overwrite.
158
+ """
159
+ if remote_head == local_tip:
160
+ return PublicationAction.ALREADY_PUBLISHED
161
+ if remote_head != expected:
162
+ return PublicationAction.REMOTE_MOVED
163
+ return PublicationAction.PUSH
164
+
165
+
166
+ def align_with_remote(*, remote_in_local: bool, local_in_remote: bool) -> Alignment:
167
+ """Relate the task branch to the remote branch from their ancestry."""
168
+ if remote_in_local:
169
+ return Alignment.ALIGNED
170
+ if local_in_remote:
171
+ return Alignment.FAST_FORWARD
172
+ return Alignment.DIVERGED
@@ -0,0 +1,31 @@
1
+ """Markdown code fencing that survives backticks already present in its content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _BACKTICK = "`"
6
+ _MIN_FENCE_LENGTH = 3
7
+
8
+
9
+ def fence(content: str, info: str = "") -> str:
10
+ """Wrap `content` in a backtick fence longer than any backtick run already inside it.
11
+
12
+ `info` is the fence's info string (e.g. `text` or `json`). Quoting untrusted content
13
+ (an agent's report, a request or a pane screen) this way keeps a `` ``` `` or `` ```` ``
14
+ the content already contains from closing the fence early and turning the rest of it
15
+ into instructions the agent reads as ours.
16
+ """
17
+ length = max(_MIN_FENCE_LENGTH, _longest_backtick_run(content) + 1)
18
+ marker = _BACKTICK * length
19
+ return f"{marker}{info}\n{content}\n{marker}"
20
+
21
+
22
+ def _longest_backtick_run(content: str) -> int:
23
+ longest = 0
24
+ current = 0
25
+ for character in content:
26
+ if character == _BACKTICK:
27
+ current += 1
28
+ longest = max(longest, current)
29
+ else:
30
+ current = 0
31
+ return longest
@@ -0,0 +1,102 @@
1
+ """Rung selection, move-up rules and counter resets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.domain.tasks import DEFAULT_COMPLEXITY, Counters, Role, Task
9
+
10
+ if TYPE_CHECKING:
11
+ from shikiru.agents.contracts import Rung
12
+ from shikiru.config.settings import Settings
13
+
14
+ REJECTIONS_FIELD = "rejections_on_rung"
15
+
16
+
17
+ def _ladder_for(role: Role, task: Task, settings: Settings) -> list[Rung]:
18
+ if role is Role.LEADER:
19
+ return settings.roles.leader.ladder
20
+ if role is Role.IMPLEMENTER:
21
+ return settings.roles.implementer.ladder_for(task.complexity or DEFAULT_COMPLEXITY)
22
+ return settings.roles.helper.ladder
23
+
24
+
25
+ def pick_rung(task: Task, role: Role, settings: Settings) -> Rung:
26
+ """Resolve `role`'s rung: the operator override, else the ladder at `rung_index[role]`."""
27
+ override = task.overrides.get(role)
28
+ if override is not None:
29
+ return override
30
+ ladder = _ladder_for(role, task, settings)
31
+ return ladder[task.rung_index.get(role, 0)]
32
+
33
+
34
+ def reset_for_rung_change(counters: Counters) -> Counters:
35
+ """`rejections_on_rung` resets whenever the Implementer's rung changes."""
36
+ return counters.model_copy(update={REJECTIONS_FIELD: 0})
37
+
38
+
39
+ def reset_for_new_iteration(counters: Counters) -> Counters:
40
+ """`consults` resets at the start of every implement/verify iteration."""
41
+ return counters.model_copy(update={"consults": 0})
42
+
43
+
44
+ def reset_for_integration_attempt(counters: Counters) -> Counters:
45
+ """`conflict_rounds` resets at the start of every integration attempt."""
46
+ return counters.model_copy(update={"conflict_rounds": 0})
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RungMove:
51
+ """The new `rung_index`, the override to clear and `counters` after moving a role up."""
52
+
53
+ rung_index: dict[Role, int]
54
+ cleared_overrides: dict[Role, Rung]
55
+ counters: Counters
56
+
57
+
58
+ def move_up(
59
+ task: Task,
60
+ role: Role,
61
+ settings: Settings,
62
+ *,
63
+ different_agent: bool = False,
64
+ blocked_agents: frozenset[str] = frozenset(),
65
+ ) -> RungMove | None:
66
+ """Advance `role` one rung up its ladder from the rung currently resolved by `pick_rung`.
67
+
68
+ Comparing against the resolved rung, not `ladder[rung_index]`, matters because that rung may
69
+ be an operator override; `different_agent=True` then skips ladder rungs on that override's
70
+ agent too. Moving up clears `role`'s override as observed in `task`: an automatic move replaces
71
+ a manual pin, since keeping it would make the move a no-op and could loop recovery on a
72
+ blocked agent. The store clears it only while it still equals that observed rung, so a pin the
73
+ operator set since survives.
74
+ For the Implementer, `rejections_on_rung` also resets, since it counts rework at one rung.
75
+ Agents in `blocked_agents` are skipped when selecting the destination rung.
76
+
77
+ With `different_agent=True` the move is a quota fallback: rungs above the current one come
78
+ first, then the rungs from the bottom of the ladder up, so a role parked on the last rung can
79
+ return to an earlier agent that is no longer blocked.
80
+
81
+ Returns `None` once no rung satisfies the constraints; the caller then keeps the task on its
82
+ current rung.
83
+ """
84
+ ladder = _ladder_for(role, task, settings)
85
+ current_rung = pick_rung(task, role, settings)
86
+ current_index = task.rung_index.get(role, 0)
87
+ above = range(current_index + 1, len(ladder))
88
+ below = range(current_index + 1) if different_agent else range(0)
89
+ for index in (*above, *below):
90
+ candidate = ladder[index]
91
+ if candidate.agent in blocked_agents:
92
+ continue
93
+ if different_agent and candidate.agent == current_rung.agent:
94
+ continue
95
+ rung_index = dict(task.rung_index)
96
+ rung_index[role] = index
97
+ cleared = {role: task.overrides[role]} if role in task.overrides else {}
98
+ counters = (
99
+ reset_for_rung_change(task.counters) if role is Role.IMPLEMENTER else task.counters
100
+ )
101
+ return RungMove(rung_index=rung_index, cleared_overrides=cleared, counters=counters)
102
+ return None
@@ -0,0 +1,44 @@
1
+ """Pure parsing for the initializer's comma-separated ladder field."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from shikiru.agents.contracts import Rung
8
+
9
+ ENTRY_SEPARATOR = ","
10
+ PART_SEPARATOR = ":"
11
+ REQUIRED_PART_COUNT = 2
12
+ OPTIONAL_EFFORT_PART_COUNT = 3
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class LadderTextError(ValueError):
17
+ """One malformed entry in an edited ladder field."""
18
+
19
+ entry_index: int
20
+ detail: str
21
+
22
+ def __str__(self) -> str:
23
+ return self.detail
24
+
25
+
26
+ def parse_ladder_text(value: str) -> tuple[Rung, ...]:
27
+ """Parse ordered `agent:model[:effort]` entries without changing the source text."""
28
+ if not value.strip():
29
+ return ()
30
+
31
+ rungs: list[Rung] = []
32
+ for index, raw_entry in enumerate(value.split(ENTRY_SEPARATOR)):
33
+ entry = raw_entry.strip()
34
+ parts = tuple(part.strip() for part in entry.split(PART_SEPARATOR))
35
+ if len(parts) not in {REQUIRED_PART_COUNT, OPTIONAL_EFFORT_PART_COUNT}:
36
+ detail = "use agent:model or agent:model:effort"
37
+ raise LadderTextError(index, detail)
38
+ if any(not part for part in parts):
39
+ detail = "each agent, model and effort value must be non-empty"
40
+ raise LadderTextError(index, detail)
41
+ agent, model = parts[:REQUIRED_PART_COUNT]
42
+ effort = parts[-1] if len(parts) == OPTIONAL_EFFORT_PART_COUNT else None
43
+ rungs.append(Rung(agent=agent, model=model, effort=effort))
44
+ return tuple(rungs)
@@ -0,0 +1,44 @@
1
+ """Usage levels: how full a share of a limit is against its warn and alert thresholds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum, auto
6
+
7
+ WHOLE_PERCENT = 100
8
+
9
+
10
+ class UsageLevel(StrEnum):
11
+ """Whether a used share is below the warn threshold, from it, or from the alert threshold."""
12
+
13
+ OK = auto()
14
+ WARN = auto()
15
+ ALERT = auto()
16
+
17
+
18
+ def displayed_pct(used_pct: float) -> int:
19
+ """Return the whole percentage a surface shows for a used share (`41.6` shows `42`)."""
20
+ return round(used_pct)
21
+
22
+
23
+ def usage_level(used_pct: float, *, warn_pct: int, alert_pct: int) -> UsageLevel:
24
+ """Classify `used_pct` by the whole percentage shown: `alert_pct` and above is ALERT."""
25
+ shown = displayed_pct(used_pct)
26
+ if shown >= alert_pct:
27
+ return UsageLevel.ALERT
28
+ if shown >= warn_pct:
29
+ return UsageLevel.WARN
30
+ return UsageLevel.OK
31
+
32
+
33
+ def free_space_level(
34
+ free_bytes: int, total_bytes: int, *, warn_pct: int, alert_pct: int
35
+ ) -> UsageLevel:
36
+ """Classify free space below `alert_pct` percent as ALERT and below `warn_pct` as WARN.
37
+
38
+ The share is compared as exact byte counts, so it is never rounded into a level.
39
+ """
40
+ if free_bytes * WHOLE_PERCENT < alert_pct * total_bytes:
41
+ return UsageLevel.ALERT
42
+ if free_bytes * WHOLE_PERCENT < warn_pct * total_bytes:
43
+ return UsageLevel.WARN
44
+ return UsageLevel.OK