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,257 @@
1
+ """Pure derivations behind the task briefing: rework, verdict, produced work, tests and PR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.domain.reports import DECISION_ACCEPT
11
+ from shikiru.domain.tasks import Hold, Status
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping, Sequence
15
+
16
+ from shikiru.domain.reports import TestRun, Verdict
17
+
18
+ FACTS_COMMITS_HEADING = "### Commits (base..HEAD)"
19
+ FACTS_DIFF_STAT_HEADING = "### Diff stat (base...HEAD)"
20
+ FACTS_NONE_MARKER = "(none)"
21
+ PR_OPEN_STATE = "OPEN"
22
+ PR_MERGED_STATE = "MERGED"
23
+ PR_URL_KEY = "url"
24
+ PR_STATE_KEY = "state"
25
+ PR_CHECKS_PENDING_KEY = "checks_pending"
26
+ PR_CHECKS_FAILING_KEY = "checks_failing"
27
+ MAX_BRIEFING_ACTIONS = 4
28
+ FIRST_PASS_ITERATION = 1
29
+ LAST_WORDS_ELLIPSIS = "…"
30
+ _FACTS_SECTION_BREAK = "\n\n"
31
+ _FACTS_PARTS = 4
32
+ _DIFF_SUMMARY = re.compile(
33
+ r"(?P<files>\d+) files? changed"
34
+ r"(?:, (?P<insertions>\d+) insertions?\(\+\))?"
35
+ r"(?:, (?P<deletions>\d+) deletions?\(-\))?"
36
+ )
37
+ _PR_NUMBER = re.compile(r"/pull/(?P<number>\d+)/?$")
38
+
39
+
40
+ class BriefingAction(StrEnum):
41
+ """A briefing verb, declared in offer order; each value is the palette action it opens."""
42
+
43
+ ANSWER = "resolve"
44
+ TAKE_OVER = "take-over"
45
+ HAND_BACK = "hand-back"
46
+ MESSAGE = "message"
47
+ PAUSE = "pause"
48
+ RESUME = "resume"
49
+ CANCEL = "cancel"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class VerdictCounts:
54
+ """A verdict's decision and its issues counted by severity."""
55
+
56
+ decision: str
57
+ blocker: int
58
+ major: int
59
+ minor: int
60
+
61
+ @property
62
+ def has_problems(self) -> bool:
63
+ """Whether the verdict is anything but a clean accept."""
64
+ return self.decision != DECISION_ACCEPT or bool(self.blocker or self.major or self.minor)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ProducedNumbers:
69
+ """Commits, files and lines recorded in the facts snapshot."""
70
+
71
+ commits: int
72
+ files: int
73
+ insertions: int
74
+ deletions: int
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ReportedTests:
79
+ """Agent-reported test results by outcome."""
80
+
81
+ passed: int
82
+ failed: int
83
+ skipped: int
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class PullRequestLine:
88
+ """The recorded pull request; checks are set only when the engine recorded them."""
89
+
90
+ state: str
91
+ url: str | None
92
+ number: int | None
93
+ checks_pending: bool | None
94
+ checks_failing: bool | None
95
+
96
+
97
+ def escalation_remaining(
98
+ rejections_on_rung: int, escalate_after: int, *, can_escalate: bool
99
+ ) -> int | None:
100
+ """Return the rejections left before the Implementer's rung escalates automatically.
101
+
102
+ The counter counts rework at one rung; the rejection that makes it reach `escalate_after`
103
+ moves the rung up, so the remainder is only meaningful once a rejection happened and the
104
+ ladder still has a higher rung.
105
+ """
106
+ remaining = escalate_after - rejections_on_rung
107
+ if rejections_on_rung < 1 or not can_escalate or remaining < 1:
108
+ return None
109
+ return remaining
110
+
111
+
112
+ def verdict_counts(verdict: Verdict) -> VerdictCounts:
113
+ """Count `verdict`'s issues by severity beside its decision."""
114
+ return severity_counts(verdict.decision, [issue.severity for issue in verdict.issues])
115
+
116
+
117
+ def severity_counts(decision: str, severities: Sequence[str]) -> VerdictCounts:
118
+ """Count `severities` by level beside `decision`."""
119
+ return VerdictCounts(
120
+ decision,
121
+ severities.count("blocker"),
122
+ severities.count("major"),
123
+ severities.count("minor"),
124
+ )
125
+
126
+
127
+ def reported_tests(tests: Sequence[TestRun]) -> ReportedTests:
128
+ """Count `tests` by their reported result."""
129
+ results = [test.result for test in tests]
130
+ return ReportedTests(results.count("passed"), results.count("failed"), results.count("skipped"))
131
+
132
+
133
+ def _facts_blocks(facts: str | None) -> tuple[str, str] | None:
134
+ if facts is None:
135
+ return None
136
+ parts = facts.split(_FACTS_SECTION_BREAK)
137
+ if len(parts) < _FACTS_PARTS:
138
+ return None
139
+ commits_heading, commits_block, diff_heading, diff_block, *_ = parts
140
+ if commits_heading != FACTS_COMMITS_HEADING or diff_heading != FACTS_DIFF_STAT_HEADING:
141
+ return None
142
+ return commits_block, diff_block
143
+
144
+
145
+ def facts_commit_lines(facts: str | None) -> tuple[str, ...]:
146
+ """Return the one-line commits of rendered facts, empty when none or unreadable."""
147
+ blocks = _facts_blocks(facts)
148
+ if blocks is None or blocks[0] == FACTS_NONE_MARKER:
149
+ return ()
150
+ return tuple(blocks[0].splitlines())
151
+
152
+
153
+ def parse_produced(facts: str | None) -> ProducedNumbers | None:
154
+ """Read the commit count and diff summary from rendered facts, or None when unreadable.
155
+
156
+ An empty diff prints no summary line and renders as `(none)`, which is zero change.
157
+ """
158
+ blocks = _facts_blocks(facts)
159
+ if blocks is None:
160
+ return None
161
+ commits = len(facts_commit_lines(facts))
162
+ diff_block = blocks[1]
163
+ if diff_block == FACTS_NONE_MARKER:
164
+ return ProducedNumbers(commits, 0, 0, 0)
165
+ summary = _DIFF_SUMMARY.search(diff_block.strip().splitlines()[-1])
166
+ if summary is None:
167
+ return None
168
+ return ProducedNumbers(
169
+ commits,
170
+ int(summary["files"]),
171
+ int(summary["insertions"] or 0),
172
+ int(summary["deletions"] or 0),
173
+ )
174
+
175
+
176
+ def pull_request_line(data: Mapping[str, object], *, task_done: bool = False) -> PullRequestLine:
177
+ """Describe the latest recorded pull request event; an open event records no state yet.
178
+
179
+ A Done task has its pull request merged, whatever the last recorded event said.
180
+ """
181
+ url = data.get(PR_URL_KEY)
182
+ state = data.get(PR_STATE_KEY)
183
+ link = url if isinstance(url, str) else None
184
+ match = _PR_NUMBER.search(link) if link is not None else None
185
+ pending = data.get(PR_CHECKS_PENDING_KEY)
186
+ failing = data.get(PR_CHECKS_FAILING_KEY)
187
+ return PullRequestLine(
188
+ state=PR_MERGED_STATE
189
+ if task_done
190
+ else (state if isinstance(state, str) else PR_OPEN_STATE),
191
+ url=link,
192
+ number=int(match["number"]) if match is not None else None,
193
+ checks_pending=pending if isinstance(pending, bool) else None,
194
+ checks_failing=failing if isinstance(failing, bool) else None,
195
+ )
196
+
197
+
198
+ def squashed(text: str) -> str | None:
199
+ """Collapse the whitespace runs of `text` to single spaces, or None when it is blank."""
200
+ return " ".join(text.split()) or None
201
+
202
+
203
+ def one_line(text: str, limit: int) -> str | None:
204
+ """Collapse `text` to one line of at most `limit` characters, or None when it is blank."""
205
+ collapsed = squashed(text)
206
+ if collapsed is None:
207
+ return None
208
+ if len(collapsed) <= limit:
209
+ return collapsed
210
+ return collapsed[: limit - len(LAST_WORDS_ELLIPSIS)].rstrip() + LAST_WORDS_ELLIPSIS
211
+
212
+
213
+ def briefing_actions(
214
+ status: Status,
215
+ holds: Sequence[str],
216
+ *,
217
+ operator_owned: bool,
218
+ can_take_over: bool = False,
219
+ waiting: bool = False,
220
+ ) -> tuple[BriefingAction, ...]:
221
+ """Return the at most four verbs the briefing offers for a task in this state.
222
+
223
+ A decision comes first, then the ownership hand-over, a message, and the pause hold's
224
+ opposite; Cancel always closes the row of an unfinished task and a finished task offers
225
+ none. Take over is offered whenever the operator action can work (`can_take_over`, the
226
+ palette's own availability for the acting role). A waiting task has no agent to read a
227
+ message, so it offers none. An intervention hold is cleared by handing back, so it never
228
+ offers Resume.
229
+ """
230
+ if status in (Status.DONE, Status.CANCELLED):
231
+ return ()
232
+ verbs: list[BriefingAction] = []
233
+ if status is Status.ESCALATED:
234
+ verbs.append(BriefingAction.ANSWER)
235
+ if operator_owned:
236
+ verbs.append(BriefingAction.HAND_BACK)
237
+ elif can_take_over:
238
+ verbs.append(BriefingAction.TAKE_OVER)
239
+ if not waiting:
240
+ verbs.append(BriefingAction.MESSAGE)
241
+ if Hold.PAUSED.value in holds:
242
+ verbs.append(BriefingAction.RESUME)
243
+ elif not holds:
244
+ verbs.append(BriefingAction.PAUSE)
245
+ return (*verbs[: MAX_BRIEFING_ACTIONS - 1], BriefingAction.CANCEL)
246
+
247
+
248
+ def rework_progress(iteration: int, max_iterations: int) -> tuple[int, int] | None:
249
+ """Return `(reworks so far, reworks allowed)`, or None until the first rework.
250
+
251
+ `iteration` counts implement/verify rounds from 1, so the first pass is not a rework, and
252
+ the round that reaches `max_iterations` is the final one: `max_iterations - 1` reworks fit.
253
+ """
254
+ allowed = max_iterations - FIRST_PASS_ITERATION
255
+ if iteration <= FIRST_PASS_ITERATION or allowed < 1:
256
+ return None
257
+ return iteration - FIRST_PASS_ITERATION, allowed
@@ -0,0 +1,219 @@
1
+ """Operator command models, discriminated by `kind` for storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum, auto
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import Field
9
+
10
+ from shikiru.agents.contracts import Rung
11
+ from shikiru.config.settings import ComplexityName
12
+ from shikiru.domain.tasks import DomainModel, Mode, Role
13
+
14
+ COMMAND_ID_KEY = "command_id"
15
+ EVENT_TEXT_KEY = "text"
16
+ COMMAND_POLL_SECONDS = 0.25
17
+ COMMAND_RESULT_ERROR_KEY = "error"
18
+ COMMAND_REJECTED_FALLBACK = "engine rejected the command"
19
+
20
+
21
+ class CommandStatus(StrEnum):
22
+ """The `commands.status` lifecycle."""
23
+
24
+ PENDING = auto()
25
+ DONE = auto()
26
+ REJECTED = auto()
27
+
28
+
29
+ class CommandKind(StrEnum):
30
+ """The `commands.kind` discriminator."""
31
+
32
+ SUBMIT = auto()
33
+ PRIORITIZE = auto()
34
+ PAUSE = auto()
35
+ RESUME = auto()
36
+ CANCEL = auto()
37
+ RETRY = auto()
38
+ SET_RUNG = auto()
39
+ SAY = auto()
40
+ TAKE_OVER = auto()
41
+ HAND_BACK = auto()
42
+ RESOLVE = auto()
43
+ STOP = auto()
44
+
45
+
46
+ class IdentifiedCommand(DomainModel):
47
+ """A command whose effects are recognised on replay by the queue row id it is read with."""
48
+
49
+ command_id: int | None = None
50
+
51
+
52
+ class Submit(DomainModel):
53
+ """`submit "<request>" [flags]`: the queue allocates the task id when appending it."""
54
+
55
+ kind: Literal[CommandKind.SUBMIT] = CommandKind.SUBMIT
56
+ task_id: str | None = None
57
+ title: str
58
+ request: str
59
+ mode: Mode | None = None
60
+ complexity: ComplexityName | None = None
61
+ priority: int = 0
62
+ depends_on: list[str] = Field(default_factory=list)
63
+ leader: Rung | None = None
64
+ implementer: Rung | None = None
65
+
66
+
67
+ class Prioritize(DomainModel):
68
+ """`prioritize T-n N`: sets a task's dispatch priority."""
69
+
70
+ kind: Literal[CommandKind.PRIORITIZE] = CommandKind.PRIORITIZE
71
+ task_id: str
72
+ priority: int
73
+
74
+
75
+ class Pause(DomainModel):
76
+ """`pause T-n [--now]`: holds a task; `now` also interrupts and cancels the current step."""
77
+
78
+ kind: Literal[CommandKind.PAUSE] = CommandKind.PAUSE
79
+ task_id: str
80
+ now: bool = False
81
+
82
+
83
+ class Resume(DomainModel):
84
+ """`resume T-n`: clears the `paused` hold, or injects `continue` when it was `intervention`."""
85
+
86
+ kind: Literal[CommandKind.RESUME] = CommandKind.RESUME
87
+ task_id: str
88
+
89
+
90
+ class Cancel(IdentifiedCommand):
91
+ """`cancel T-n`: cancels a task and keeps its branch."""
92
+
93
+ kind: Literal[CommandKind.CANCEL] = CommandKind.CANCEL
94
+ task_id: str
95
+
96
+
97
+ class Retry(IdentifiedCommand):
98
+ """`retry T-n`: cancels and re-runs the task's current step."""
99
+
100
+ kind: Literal[CommandKind.RETRY] = CommandKind.RETRY
101
+ task_id: str
102
+
103
+
104
+ class SetRung(DomainModel):
105
+ """`rung T-n <role> <agent:model:effort>`: overrides a role's rung from the next turn."""
106
+
107
+ kind: Literal[CommandKind.SET_RUNG] = CommandKind.SET_RUNG
108
+ task_id: str
109
+ role: Role
110
+ rung: Rung
111
+
112
+
113
+ class Say(IdentifiedCommand):
114
+ """`say T-n <role> "<text>"`: queues a message for the role's next turn.
115
+
116
+ The queue fills `command_id` so a replayed delivery is recognised.
117
+ """
118
+
119
+ kind: Literal[CommandKind.SAY] = CommandKind.SAY
120
+ task_id: str
121
+ role: Role
122
+ text: str
123
+
124
+
125
+ class TakeOver(IdentifiedCommand):
126
+ """`take-over T-n <role>`: stops the turn and opens the role's session in its pane."""
127
+
128
+ kind: Literal[CommandKind.TAKE_OVER] = CommandKind.TAKE_OVER
129
+ task_id: str
130
+ role: Role
131
+
132
+
133
+ class HandBack(IdentifiedCommand):
134
+ """`hand-back T-n <role>`: returns a taken-over session to the engine.
135
+
136
+ The queue fills `command_id` so a replayed delivery is recognised.
137
+ """
138
+
139
+ kind: Literal[CommandKind.HAND_BACK] = CommandKind.HAND_BACK
140
+ task_id: str
141
+ role: Role
142
+
143
+
144
+ class ResolutionKind(StrEnum):
145
+ """The `resolve` answer's discriminator."""
146
+
147
+ ANSWER = auto()
148
+ RETRY = auto()
149
+ ACCEPT = auto()
150
+ CANCEL = auto()
151
+
152
+
153
+ class AnswerResolution(DomainModel):
154
+ """`resolve E-n <answer TEXT>`: runs a consult turn with the Operator's text."""
155
+
156
+ kind: Literal[ResolutionKind.ANSWER] = ResolutionKind.ANSWER
157
+ text: str
158
+
159
+
160
+ class RetryResolution(DomainModel):
161
+ """`resolve E-n retry [--rung a:m:e]`: re-runs the failed step, optionally on a new rung."""
162
+
163
+ kind: Literal[ResolutionKind.RETRY] = ResolutionKind.RETRY
164
+ rung: Rung | None = None
165
+
166
+
167
+ class AcceptResolution(DomainModel):
168
+ """`resolve E-n accept`: integrates the task as-is."""
169
+
170
+ kind: Literal[ResolutionKind.ACCEPT] = ResolutionKind.ACCEPT
171
+
172
+
173
+ class CancelResolution(DomainModel):
174
+ """`resolve E-n cancel`: cancels the task and keeps its branch."""
175
+
176
+ kind: Literal[ResolutionKind.CANCEL] = ResolutionKind.CANCEL
177
+
178
+
179
+ Resolution = Annotated[
180
+ AnswerResolution | RetryResolution | AcceptResolution | CancelResolution,
181
+ Field(discriminator="kind"),
182
+ ]
183
+
184
+
185
+ class Resolve(IdentifiedCommand):
186
+ """`resolve E-n <...>`: resolves an open escalation."""
187
+
188
+ kind: Literal[CommandKind.RESOLVE] = CommandKind.RESOLVE
189
+ escalation_id: str
190
+ resolution: Resolution
191
+
192
+
193
+ class Stop(DomainModel):
194
+ """`stop`: stops the engine; panes and their turns keep running."""
195
+
196
+ kind: Literal[CommandKind.STOP] = CommandKind.STOP
197
+
198
+
199
+ Command = Annotated[
200
+ Submit
201
+ | Prioritize
202
+ | Pause
203
+ | Resume
204
+ | Cancel
205
+ | Retry
206
+ | SetRung
207
+ | Say
208
+ | TakeOver
209
+ | HandBack
210
+ | Resolve
211
+ | Stop,
212
+ Field(discriminator="kind"),
213
+ ]
214
+
215
+
216
+ def command_rejection_cause(result: dict[str, object] | None) -> str:
217
+ """Return the engine's `error` text from a rejected command's result, else a fixed phrase."""
218
+ error = None if result is None else result.get(COMMAND_RESULT_ERROR_KEY)
219
+ return error if isinstance(error, str) else COMMAND_REJECTED_FALLBACK
@@ -0,0 +1,231 @@
1
+ """Pure derivation of a task's Decisions: a chronological log of who decided what."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ from shikiru.domain.briefing import pull_request_line, squashed
10
+ from shikiru.domain.commands import EVENT_TEXT_KEY, ResolutionKind
11
+ from shikiru.domain.events import EventKind
12
+ from shikiru.domain.reports import (
13
+ DECISION_ACCEPT,
14
+ DECISION_ESCALATE,
15
+ DECISION_SUPERSEDE,
16
+ SEVERITY_BLOCKER,
17
+ )
18
+ from shikiru.domain.tasks import Hold
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable, Sequence
22
+ from datetime import datetime
23
+
24
+ from shikiru.domain.events import Event
25
+
26
+ MAX_DETAIL_LINES = 3
27
+ DECISION_KEY = "decision"
28
+ SUMMARY_KEY = "summary"
29
+ ISSUES_KEY = "issues"
30
+ SEVERITY_KEY = "severity"
31
+ DESCRIPTION_KEY = "description"
32
+ LOCATION_KEY = "location"
33
+ RESOLUTION_KEY = "resolution"
34
+ ROLE_KEY = "role"
35
+ HOLD_KEY = "hold"
36
+ NEW_TASKS_KEY = "new_task_ids"
37
+ SCOPE_CHANGES_KEY = "scope_changes"
38
+ RUNG_FROM_KEY = "from"
39
+ RUNG_TO_KEY = "to"
40
+ OPERATOR_KEY = "operator"
41
+ _SEVERITY_ORDER = {SEVERITY_BLOCKER: 0, "major": 1, "minor": 2}
42
+ _UNRANKED = len(_SEVERITY_ORDER)
43
+ _VERDICT_WORDS = {DECISION_ACCEPT: "accepted", DECISION_SUPERSEDE: "superseded"}
44
+
45
+
46
+ class Decider(StrEnum):
47
+ """Who made a decision, as the Decisions rows name them."""
48
+
49
+ LEADER = "Leader"
50
+ YOU = "You"
51
+ ENGINE = "Engine"
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class DecisionEntry:
56
+ """One decision: when, by whom, a what/why headline and its capped detail lines."""
57
+
58
+ at: datetime
59
+ decider: Decider
60
+ headline: str
61
+ details: tuple[str, ...]
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class TaskDecisions:
66
+ """A task's decisions, oldest first."""
67
+
68
+ entries: tuple[DecisionEntry, ...]
69
+
70
+
71
+ def _text(data: dict[str, object], key: str) -> str | None:
72
+ value = data.get(key)
73
+ return squashed(value) if isinstance(value, str) else None
74
+
75
+
76
+ def _items(data: dict[str, object], key: str) -> list[object]:
77
+ value = data.get(key)
78
+ return cast("list[object]", value) if isinstance(value, list) else []
79
+
80
+
81
+ def _entry(event: Event, decider: Decider, headline: str, *details: str) -> DecisionEntry:
82
+ return DecisionEntry(event.ts, decider, headline, details[:MAX_DETAIL_LINES])
83
+
84
+
85
+ def _issue_line(issue: dict[str, object]) -> str | None:
86
+ description = _text(issue, DESCRIPTION_KEY)
87
+ location = _text(issue, LOCATION_KEY)
88
+ if description is None:
89
+ return None
90
+ return f"{description} ({location})" if location else description
91
+
92
+
93
+ def _issue_lines(data: dict[str, object]) -> list[str]:
94
+ issues = cast(
95
+ "list[dict[str, object]]",
96
+ [issue for issue in _items(data, ISSUES_KEY) if isinstance(issue, dict)],
97
+ )
98
+ ranked = sorted(
99
+ issues,
100
+ key=lambda issue: _SEVERITY_ORDER.get(str(issue.get(SEVERITY_KEY)), _UNRANKED),
101
+ )
102
+ return [line for line in map(_issue_line, ranked) if line is not None]
103
+
104
+
105
+ def _verdict(event: Event, _: str | None) -> DecisionEntry | None:
106
+ decision = str(event.data.get(DECISION_KEY))
107
+ if decision == DECISION_ESCALATE:
108
+ return None
109
+ summary = _text(event.data, SUMMARY_KEY)
110
+ headline = _VERDICT_WORDS.get(decision, decision.replace("_", " "))
111
+ return _entry(
112
+ event, Decider.LEADER, headline, *([summary] if summary else []), *_issue_lines(event.data)
113
+ )
114
+
115
+
116
+ def _escalation(event: Event, _: str | None) -> DecisionEntry:
117
+ question = squashed(event.message)
118
+ return _entry(event, Decider.LEADER, f"escalated: {question}" if question else "escalated")
119
+
120
+
121
+ def _resolution(event: Event, _: str | None) -> DecisionEntry | None:
122
+ answer = _text(event.data, EVENT_TEXT_KEY)
123
+ headlines = {
124
+ ResolutionKind.ANSWER.value: f'answered "{answer}"' if answer else "answered",
125
+ ResolutionKind.RETRY.value: "retried",
126
+ ResolutionKind.ACCEPT.value: "accepted as is",
127
+ }
128
+ headline = headlines.get(str(event.data.get(RESOLUTION_KEY)))
129
+ return _entry(event, Decider.YOU, headline) if headline is not None else None
130
+
131
+
132
+ def _scope_change(event: Event, _: str | None) -> DecisionEntry:
133
+ added = [str(task) for task in _items(event.data, NEW_TASKS_KEY)]
134
+ scope = [
135
+ text for text in map(squashed, map(str, _items(event.data, SCOPE_CHANGES_KEY))) if text
136
+ ]
137
+ return _entry(
138
+ event, Decider.LEADER, f"added {', '.join(added)}" if added else "scope changed", *scope
139
+ )
140
+
141
+
142
+ def _role(event: Event) -> str:
143
+ return str(event.data.get(ROLE_KEY)).capitalize()
144
+
145
+
146
+ def _rung_change(event: Event, _: str | None) -> DecisionEntry:
147
+ decider = Decider.YOU if event.data.get(OPERATOR_KEY) else Decider.ENGINE
148
+ old = event.data.get(RUNG_FROM_KEY)
149
+ lead = (
150
+ f"{_role(event)}: {old} \N{RIGHTWARDS ARROW}"
151
+ if old
152
+ else f"{_role(event)} \N{RIGHTWARDS ARROW}"
153
+ )
154
+ return _entry(event, decider, f"{lead} {event.data.get(RUNG_TO_KEY)}")
155
+
156
+
157
+ def _operator_message(event: Event, _: str | None) -> DecisionEntry:
158
+ text = _text(event.data, EVENT_TEXT_KEY)
159
+ return _entry(
160
+ event,
161
+ Decider.YOU,
162
+ f'messaged {_role(event)} "{text}"' if text else f"messaged {_role(event)}",
163
+ )
164
+
165
+
166
+ def _you(headline: str) -> Callable[[Event, str | None], DecisionEntry]:
167
+ return lambda event, _: _entry(event, Decider.YOU, headline)
168
+
169
+
170
+ def _role_action(verb: str) -> Callable[[Event, str | None], DecisionEntry]:
171
+ return lambda event, _: _entry(event, Decider.YOU, f"{verb} {_role(event)}")
172
+
173
+
174
+ def _paused(headline: str) -> Callable[[Event, str | None], DecisionEntry | None]:
175
+ def build(event: Event, _: str | None) -> DecisionEntry | None:
176
+ return (
177
+ _entry(event, Decider.YOU, headline)
178
+ if event.data.get(HOLD_KEY) == Hold.PAUSED.value
179
+ else None
180
+ )
181
+
182
+ return build
183
+
184
+
185
+ def _pull_request(verb: str) -> Callable[[Event, str | None], DecisionEntry]:
186
+ def build(event: Event, _: str | None) -> DecisionEntry:
187
+ number = pull_request_line(event.data).number
188
+ return _entry(event, Decider.ENGINE, f"PR #{number} {verb}" if number else f"PR {verb}")
189
+
190
+ return build
191
+
192
+
193
+ def _done(event: Event, pull_request: str | None) -> DecisionEntry:
194
+ return _entry(event, Decider.ENGINE, f"{pull_request} merged" if pull_request else "merged")
195
+
196
+
197
+ _BUILDERS: dict[EventKind, Callable[[Event, str | None], DecisionEntry | None]] = {
198
+ EventKind.VERDICT: _verdict,
199
+ EventKind.ESCALATION: _escalation,
200
+ EventKind.ESCALATION_RESOLVED: _resolution,
201
+ EventKind.SCOPE_CHANGE: _scope_change,
202
+ EventKind.RUNG_CHANGED: _rung_change,
203
+ EventKind.OPERATOR_MESSAGE: _operator_message,
204
+ EventKind.TAKEN_OVER: _role_action("took over"),
205
+ EventKind.HANDED_BACK: _role_action("handed back"),
206
+ EventKind.HOLD_SET: _paused("paused"),
207
+ EventKind.HOLD_CLEARED: _paused("resumed"),
208
+ EventKind.TASK_RETRIED: _you("retried"),
209
+ EventKind.TASK_CANCELLED: _you("cancelled"),
210
+ EventKind.PR_OPENED: _pull_request("opened"),
211
+ EventKind.PR_WITHDRAWN: _pull_request("withdrawn"),
212
+ EventKind.TASK_DONE: _done,
213
+ }
214
+
215
+
216
+ def derive_decisions(events: Sequence[Event]) -> TaskDecisions:
217
+ """Derive a task's decision log from its recorded events, oldest first.
218
+
219
+ Events of other kinds are ignored; a cancelling resolution is shown by the cancel event.
220
+ """
221
+ entries: list[DecisionEntry] = []
222
+ pull_request: str | None = None
223
+ for event in sorted(events, key=lambda recorded: recorded.id):
224
+ if event.kind is EventKind.PR_OPENED:
225
+ number = pull_request_line(event.data).number
226
+ pull_request = f"PR #{number}" if number else "PR"
227
+ builder = _BUILDERS.get(event.kind)
228
+ entry = builder(event, pull_request) if builder is not None else None
229
+ if entry is not None:
230
+ entries.append(entry)
231
+ return TaskDecisions(tuple(entries))