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,217 @@
1
+ """Pure derivation of a task's Results from recorded data: no git call, nothing guessed."""
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.config.settings import SQUASH_INTEGRATION
10
+ from shikiru.domain.briefing import ProducedNumbers, facts_commit_lines, parse_produced
11
+ from shikiru.domain.tasks import Status
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping, Sequence
15
+
16
+ from shikiru.domain.briefing import PullRequestLine, ReportedTests
17
+ from shikiru.domain.reports import Commit
18
+
19
+ SHORT_SHA_LENGTH = 7
20
+ DONE_COMMITS_KEY = "commits"
21
+ DONE_RISKS_KEY = "residual_risks"
22
+ DONE_INTEGRATION_KEY = "integration"
23
+ DONE_CHANGES_KEY = "changes"
24
+ CHANGES_FILES_KEY = "files"
25
+ CHANGES_INSERTIONS_KEY = "insertions"
26
+ CHANGES_DELETIONS_KEY = "deletions"
27
+ SCOPE_CHANGES_KEY = "scope_changes"
28
+ NEW_TASK_IDS_KEY = "new_task_ids"
29
+
30
+
31
+ class ResultOutcome(StrEnum):
32
+ """Whether the task's work reached its base branch."""
33
+
34
+ MERGED = "merged"
35
+ NOT_MERGED = "not merged"
36
+ SO_FAR = "so far"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ResultCommit:
41
+ """A commit as a short hash and its subject."""
42
+
43
+ sha: str
44
+ subject: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class LiveWork:
49
+ """A task's work as its worktree holds it now: commits past its base and the changes."""
50
+
51
+ commits: tuple[ResultCommit, ...]
52
+ produced: ProducedNumbers
53
+
54
+ @classmethod
55
+ def from_oneline(
56
+ cls, commit_lines: Sequence[str], *, files: int, insertions: int, deletions: int
57
+ ) -> LiveWork:
58
+ """Build the observation from `git log --oneline` lines and the change totals."""
59
+ commits = tuple(_from_oneline(line) for line in commit_lines)
60
+ return cls(commits, ProducedNumbers(len(commits), files, insertions, deletions))
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class PlanChange:
65
+ """One recorded scope change: what changed and the tasks it created as (id, title or None)."""
66
+
67
+ scope_changes: tuple[str, ...]
68
+ new_tasks: tuple[tuple[str, str | None], ...]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class TaskResults:
73
+ """What a task produced; `more_commits` counts the commits beyond the capped list.
74
+
75
+ `live_error` is set when the task's current Git state could not be read, so the commits and
76
+ numbers shown are the recorded ones and must be labelled as such.
77
+ """
78
+
79
+ outcome: ResultOutcome
80
+ branch: str | None
81
+ base: str | None
82
+ commits: tuple[ResultCommit, ...]
83
+ more_commits: int
84
+ produced: ProducedNumbers | None
85
+ tests: ReportedTests | None
86
+ pull_request: PullRequestLine | None
87
+ plan_changes: tuple[PlanChange, ...]
88
+ plan_summary: str | None
89
+ residual_risks: tuple[str, ...]
90
+ live_error: str | None
91
+ squashed: bool = False
92
+
93
+
94
+ def _strings(value: object) -> tuple[str, ...]:
95
+ items = cast("list[object]", value) if isinstance(value, list) else []
96
+ return tuple(item for item in items if isinstance(item, str))
97
+
98
+
99
+ def _from_oneline(line: str) -> ResultCommit:
100
+ sha, _, subject = line.partition(" ")
101
+ return ResultCommit(sha[:SHORT_SHA_LENGTH], subject.strip())
102
+
103
+
104
+ def _outcome(status: Status) -> ResultOutcome:
105
+ if status is Status.DONE:
106
+ return ResultOutcome.MERGED
107
+ return ResultOutcome.NOT_MERGED if status is Status.CANCELLED else ResultOutcome.SO_FAR
108
+
109
+
110
+ def tracks_live_work(status: Status) -> bool:
111
+ """Whether a task still shows live work; a finished one keeps its recorded Results."""
112
+ return status not in (Status.DONE, Status.CANCELLED)
113
+
114
+
115
+ def new_task_ids(scope_events: Sequence[Mapping[str, object]]) -> tuple[str, ...]:
116
+ """Return the distinct ids of the tasks the recorded scope changes created, sorted."""
117
+ return tuple(
118
+ sorted(
119
+ {task_id for data in scope_events for task_id in _strings(data.get(NEW_TASK_IDS_KEY))}
120
+ )
121
+ )
122
+
123
+
124
+ def landed_changes(done: Mapping[str, object]) -> tuple[int, int, int] | None:
125
+ """Return the recorded (files, insertions, deletions) of `done`, or `None` when absent."""
126
+ changes = done.get(DONE_CHANGES_KEY)
127
+ if not isinstance(changes, dict):
128
+ return None
129
+ found = cast("dict[str, object]", changes)
130
+ counts = tuple(
131
+ found.get(key) for key in (CHANGES_FILES_KEY, CHANGES_INSERTIONS_KEY, CHANGES_DELETIONS_KEY)
132
+ )
133
+ if not all(isinstance(count, int) for count in counts):
134
+ return None
135
+ files, insertions, deletions = cast("tuple[int, int, int]", counts)
136
+ return files, insertions, deletions
137
+
138
+
139
+ def _residual_risks(done: Mapping[str, object], verdict_risks: Sequence[str]) -> tuple[str, ...]:
140
+ if DONE_RISKS_KEY in done:
141
+ return _strings(done[DONE_RISKS_KEY])
142
+ return tuple(verdict_risks)
143
+
144
+
145
+ def derive_task_results(
146
+ status: Status,
147
+ *,
148
+ branch: str | None,
149
+ base: str | None,
150
+ done: Mapping[str, object] | None,
151
+ report_commits: Sequence[Commit],
152
+ facts: str | None,
153
+ tests: ReportedTests | None,
154
+ pull_request: PullRequestLine | None,
155
+ scope_events: Sequence[Mapping[str, object]],
156
+ titles: Mapping[str, str],
157
+ verdict_risks: Sequence[str],
158
+ plan_summary: str | None,
159
+ commit_cap: int,
160
+ live: LiveWork | None = None,
161
+ live_error: str | None = None,
162
+ ) -> TaskResults:
163
+ """Derive the Results of a task from its recorded events, reports and facts snapshot.
164
+
165
+ A live observation of the task's worktree (`live`) is authoritative for its commits and
166
+ produced numbers, even when it finds none, so a report or facts snapshot that predates a
167
+ commit or revert never shows. Without one, a `TASK_DONE` that recorded the landed changes of
168
+ its integration is authoritative for the commits (even none) and the numbers, so neither report
169
+ commits nor the facts of the pre-integration branch show. Otherwise commits come from
170
+ `TASK_DONE` for a Done task,
171
+ else the latest report, else the facts; the first source with commits wins, and the numbers
172
+ come from the facts. Residual risks come from `TASK_DONE` when it recorded a risk list, even
173
+ an empty one, else from `verdict_risks`: the verdict of the task's current iteration. A new
174
+ task missing from `titles` keeps its id with no title.
175
+ """
176
+ done_data = done or {}
177
+ landed = landed_changes(done_data) if live is None else None
178
+ if live is not None:
179
+ commits, produced = live.commits, live.produced
180
+ elif landed is not None:
181
+ commits = tuple(_from_oneline(line) for line in _strings(done_data.get(DONE_COMMITS_KEY)))
182
+ produced = ProducedNumbers(len(commits), *landed)
183
+ else:
184
+ candidates = (
185
+ tuple(_from_oneline(line) for line in _strings(done_data.get(DONE_COMMITS_KEY))),
186
+ tuple(
187
+ ResultCommit(commit.sha[:SHORT_SHA_LENGTH], commit.subject)
188
+ for commit in report_commits
189
+ ),
190
+ tuple(_from_oneline(line) for line in facts_commit_lines(facts)),
191
+ )
192
+ commits = next((found for found in candidates if found), ())
193
+ produced = parse_produced(facts)
194
+ return TaskResults(
195
+ outcome=_outcome(status),
196
+ branch=branch,
197
+ base=base,
198
+ commits=commits[:commit_cap],
199
+ more_commits=max(len(commits) - commit_cap, 0),
200
+ produced=produced,
201
+ tests=tests,
202
+ pull_request=pull_request,
203
+ plan_changes=tuple(
204
+ PlanChange(
205
+ _strings(data.get(SCOPE_CHANGES_KEY)),
206
+ tuple(
207
+ (task_id, titles.get(task_id))
208
+ for task_id in _strings(data.get(NEW_TASK_IDS_KEY))
209
+ ),
210
+ )
211
+ for data in scope_events
212
+ ),
213
+ plan_summary=plan_summary,
214
+ residual_risks=_residual_risks(done_data, verdict_risks),
215
+ live_error=live_error,
216
+ squashed=landed is not None and done_data.get(DONE_INTEGRATION_KEY) == SQUASH_INTEGRATION,
217
+ )
shikiru/domain/runs.py ADDED
@@ -0,0 +1,85 @@
1
+ """Agent run model: one row per task role's session lineage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import StrEnum, auto
7
+
8
+ from shikiru.agents.contracts import (
9
+ BILLING_MODE_UNKNOWN,
10
+ BILLING_SOURCE_UNKNOWN,
11
+ BillingMode,
12
+ BillingSource,
13
+ Usage,
14
+ )
15
+ from shikiru.domain.levels import displayed_pct
16
+ from shikiru.domain.tasks import DomainModel, Role
17
+
18
+ NO_USAGE = Usage(
19
+ input_tokens=0,
20
+ output_tokens=0,
21
+ cache_read_tokens=0,
22
+ cache_write_tokens=0,
23
+ cost_usd=None,
24
+ cost_basis="unknown",
25
+ context_used_pct=None,
26
+ )
27
+
28
+
29
+ class RunStatus(StrEnum):
30
+ """A run's lifecycle."""
31
+
32
+ LIVE = auto()
33
+ TAKEN_OVER = auto()
34
+ CLOSED = auto()
35
+
36
+
37
+ class Run(DomainModel):
38
+ """One agent session for a task role (mirrors the `runs` table)."""
39
+
40
+ id: int
41
+ task_id: str
42
+ role: Role
43
+ agent: str
44
+ model: str
45
+ effort: str | None = None
46
+ session_id: str | None = None
47
+ resume_command: str | None = None
48
+ last_message: str | None = None
49
+ status: RunStatus
50
+ usage: Usage = NO_USAGE
51
+ usage_reported: bool | None = None
52
+ billing_mode: BillingMode = BILLING_MODE_UNKNOWN
53
+ billing_source: BillingSource = BILLING_SOURCE_UNKNOWN
54
+ started_at: datetime
55
+ ended_at: datetime | None = None
56
+
57
+
58
+ def has_positive_token_evidence(run: Run) -> bool:
59
+ """Return whether `run` records a positive token count in any usage field."""
60
+ return any(
61
+ token > 0
62
+ for token in (
63
+ run.usage.input_tokens,
64
+ run.usage.output_tokens,
65
+ run.usage.cache_read_tokens,
66
+ run.usage.cache_write_tokens,
67
+ )
68
+ )
69
+
70
+
71
+ MISSING_TITLE_VALUE = "\N{EN DASH}"
72
+
73
+
74
+ def context_title(usage: Usage) -> str:
75
+ """Format `usage`'s context share for a title (`42%`), or a dash if unreported."""
76
+ if usage.context_used_pct is None:
77
+ return MISSING_TITLE_VALUE
78
+ return f"{displayed_pct(usage.context_used_pct)}%"
79
+
80
+
81
+ def cost_title(usage: Usage) -> str:
82
+ """Format `usage`'s cost for a title (`$1.84`), or a dash if unreported."""
83
+ if usage.cost_usd is None:
84
+ return MISSING_TITLE_VALUE
85
+ return f"${usage.cost_usd:.2f}"
@@ -0,0 +1,231 @@
1
+ """Ready-set computation and dispatch gating."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum, auto
7
+ from graphlib import CycleError, TopologicalSorter
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.domain.ladder import move_up, pick_rung
11
+ from shikiru.domain.steps import task_acting_role
12
+ from shikiru.domain.tasks import Role, Status
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterable, Mapping, Sequence
16
+ from datetime import datetime
17
+
18
+ from shikiru.config.settings import Settings
19
+ from shikiru.domain.tasks import Task
20
+
21
+
22
+ class WaitReason(StrEnum):
23
+ """Why a task is waiting instead of running."""
24
+
25
+ PAUSED = auto()
26
+ DEPENDENCY = auto()
27
+ SLOT = auto()
28
+ RESOURCES = auto()
29
+ QUOTA = auto()
30
+ PR_OPEN = "PR open"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TaskAgents:
35
+ """The agent names a task would use for the Leader and the Implementer roles."""
36
+
37
+ leader: str
38
+ implementer: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class SchedulingGates:
43
+ """Pure data the scheduler gathers before a dispatch pass."""
44
+
45
+ free_slots: frozenset[int]
46
+ resources_ok: bool
47
+ blocked_agents: frozenset[str]
48
+ agents: Mapping[str, TaskAgents]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Dispatch:
53
+ """A task to dispatch into `slot`."""
54
+
55
+ task_id: str
56
+ slot: int
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ReadySchedule:
61
+ """The result of one scheduling pass."""
62
+
63
+ dispatches: tuple[Dispatch, ...]
64
+ wait_reasons: Mapping[str, WaitReason]
65
+ dependency_cancelled: tuple[str, ...]
66
+
67
+
68
+ def find_dependency_cycle(dependencies: Mapping[str, Iterable[str]]) -> tuple[str, ...]:
69
+ """Return the task ids on one dependency cycle (a self-edge included), or none."""
70
+ try:
71
+ TopologicalSorter(dependencies).prepare()
72
+ except CycleError as error:
73
+ return tuple(dict.fromkeys(error.args[1]))
74
+ return ()
75
+
76
+
77
+ def dispatch_sort_key(task: Task) -> tuple[bool, int, int]:
78
+ """Order `task` the way dispatch does: requeued first, then priority, then submit order."""
79
+ return (task.requeued_at is None, -task.priority, task.seq)
80
+
81
+
82
+ def occupied_slot_ids(tasks: Sequence[Task]) -> frozenset[int]:
83
+ """Return distinct slots held by running or integrating tasks."""
84
+ return frozenset(
85
+ task.slot
86
+ for task in tasks
87
+ if task.status in (Status.RUNNING, Status.INTEGRATING) and task.slot is not None
88
+ )
89
+
90
+
91
+ def task_agents(task: Task, settings: Settings) -> TaskAgents:
92
+ """Resolve the Leader and Implementer agents `task` would use for its next turns."""
93
+ return TaskAgents(
94
+ leader=pick_rung(task, Role.LEADER, settings).agent,
95
+ implementer=pick_rung(task, Role.IMPLEMENTER, settings).agent,
96
+ )
97
+
98
+
99
+ def quota_blocked_agents(agents: TaskAgents, blocked_until: Mapping[str, datetime]) -> list[str]:
100
+ """Return each agent of `agents` that is blocked, once each, Leader first."""
101
+ return [
102
+ agent
103
+ for agent in dict.fromkeys((agents.leader, agents.implementer))
104
+ if agent in blocked_until
105
+ ]
106
+
107
+
108
+ def quota_blocking_agent(agents: TaskAgents, blocked_until: Mapping[str, datetime]) -> str | None:
109
+ """Return the blocked agent of `agents` with the latest deadline; Leader wins ties."""
110
+ blocked = quota_blocked_agents(agents, blocked_until)
111
+ return max(blocked, key=blocked_until.__getitem__, default=None)
112
+
113
+
114
+ def quota_wait_reroutes(
115
+ task: Task,
116
+ settings: Settings,
117
+ blocked_until: Mapping[str, datetime],
118
+ wait_limit: datetime,
119
+ ) -> bool:
120
+ """Return whether the scheduler moves `task` to another agent: its block outlasts `wait_limit`.
121
+
122
+ A role moves when its resolved agent is blocked beyond `wait_limit` and a rung on a different,
123
+ unblocked agent exists, a higher one first, else one below; this is the rule the scheduler
124
+ applies to queued tasks.
125
+ """
126
+ blocked_agents = frozenset(blocked_until)
127
+ for role in (Role.LEADER, Role.IMPLEMENTER):
128
+ until = blocked_until.get(pick_rung(task, role, settings).agent)
129
+ if until is None or until <= wait_limit:
130
+ continue
131
+ moved = move_up(task, role, settings, different_agent=True, blocked_agents=blocked_agents)
132
+ if moved is not None:
133
+ return True
134
+ return False
135
+
136
+
137
+ def running_quota_wait_moves(
138
+ task: Task,
139
+ settings: Settings,
140
+ block_until: datetime,
141
+ budget_ends: datetime,
142
+ blocked_agents: frozenset[str],
143
+ ) -> bool:
144
+ """Return whether a running turn's quota wait moves `task` to another agent when it gives up.
145
+
146
+ The engine fixes the wait's deadline at `budget_ends` when the wait starts. It gives up only
147
+ if the block still outlasts that deadline, and then moves the acting role to a different agent
148
+ when one exists that is not in `blocked_agents`, the rule the engine's recovery applies.
149
+ """
150
+ if block_until <= budget_ends:
151
+ return False
152
+ moved = move_up(
153
+ task,
154
+ task_acting_role(task),
155
+ settings,
156
+ different_agent=True,
157
+ blocked_agents=blocked_agents,
158
+ )
159
+ return moved is not None
160
+
161
+
162
+ def _group_queued_tasks(
163
+ tasks: Sequence[Task], by_id: Mapping[str, Task]
164
+ ) -> tuple[list[Task], dict[str, WaitReason], list[str]]:
165
+ ready: list[Task] = []
166
+ wait_reasons: dict[str, WaitReason] = {}
167
+ dependency_cancelled: list[str] = []
168
+ for task in tasks:
169
+ if task.status != Status.QUEUED:
170
+ continue
171
+ if task.held:
172
+ wait_reasons[task.id] = WaitReason.PAUSED
173
+ continue
174
+ deps = [by_id[dep_id] for dep_id in task.depends_on]
175
+ if any(dep.status == Status.CANCELLED for dep in deps):
176
+ dependency_cancelled.append(task.id)
177
+ continue
178
+ if all(dep.status == Status.DONE for dep in deps):
179
+ ready.append(task)
180
+ else:
181
+ wait_reasons[task.id] = WaitReason.DEPENDENCY
182
+ return ready, wait_reasons, dependency_cancelled
183
+
184
+
185
+ def pick_ready(tasks: Sequence[Task], gates: SchedulingGates) -> ReadySchedule:
186
+ """Return the ready set in dispatch order, gated by slots, resources and quota.
187
+
188
+ `tasks` is every task the caller tracks, so dependency statuses can be looked up by id; an
189
+ id that isn't in `tasks` is a caller bug and raises `KeyError`, as does a ready task missing
190
+ from `gates.agents`.
191
+
192
+ Ready tasks are ordered with `requeued_at` set first, then `priority` descending, then `seq`.
193
+ Slot and resources are gates shared by the whole pass: the first one that fails stops
194
+ dispatch there, and every task from that point on shares its `wait_reason`, since a
195
+ lower-priority task never dispatches ahead of a blocked one. Quota is scoped to one task at a
196
+ time (a quota block is the only cross-task effect, but it is still per-agent, not
197
+ per-pass): a quota-blocked task is skipped and dispatch continues with the next one.
198
+ """
199
+ by_id = {task.id: task for task in tasks}
200
+ ready, wait_reasons, dependency_cancelled = _group_queued_tasks(tasks, by_id)
201
+
202
+ ordered = sorted(ready, key=dispatch_sort_key)
203
+ remaining_slots = set(gates.free_slots)
204
+ dispatches: list[Dispatch] = []
205
+ stopped_reason: WaitReason | None = None
206
+
207
+ for task in ordered:
208
+ if stopped_reason is not None:
209
+ wait_reasons[task.id] = stopped_reason
210
+ continue
211
+ if not remaining_slots:
212
+ stopped_reason = WaitReason.SLOT
213
+ wait_reasons[task.id] = stopped_reason
214
+ continue
215
+ if not gates.resources_ok:
216
+ stopped_reason = WaitReason.RESOURCES
217
+ wait_reasons[task.id] = stopped_reason
218
+ continue
219
+ agents = gates.agents[task.id]
220
+ if agents.leader in gates.blocked_agents or agents.implementer in gates.blocked_agents:
221
+ wait_reasons[task.id] = WaitReason.QUOTA
222
+ continue
223
+ slot = task.slot if task.slot in remaining_slots else min(remaining_slots)
224
+ remaining_slots.discard(slot)
225
+ dispatches.append(Dispatch(task_id=task.id, slot=slot))
226
+
227
+ return ReadySchedule(
228
+ dispatches=tuple(dispatches),
229
+ wait_reasons=wait_reasons,
230
+ dependency_cancelled=tuple(dependency_cancelled),
231
+ )
@@ -0,0 +1,40 @@
1
+ """Human project status task selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.scheduling import dispatch_sort_key
8
+ from shikiru.domain.tasks import Status
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Sequence
12
+
13
+ from shikiru.domain.tasks import Task
14
+
15
+ HUMAN_STATUS_TASK_LIMIT = 10
16
+ _OTHER_ACTIVE_STATUSES = (Status.RUNNING, Status.INTEGRATING)
17
+
18
+
19
+ def select_human_status_tasks(tasks: Sequence[Task]) -> tuple[Task, ...]:
20
+ """Select at most ten tasks, reserving the final row for a latest recorded completion."""
21
+ escalated = sorted(
22
+ (task for task in tasks if task.status is Status.ESCALATED), key=lambda task: task.seq
23
+ )
24
+ active = sorted(
25
+ (task for task in tasks if task.status in _OTHER_ACTIVE_STATUSES),
26
+ key=lambda task: task.seq,
27
+ )
28
+ queued = sorted((task for task in tasks if task.status is Status.QUEUED), key=dispatch_sort_key)
29
+ completed = [task for task in tasks if task.status is Status.DONE]
30
+ with_finish_time = [task for task in completed if task.finished_at is not None]
31
+ done = (
32
+ max(with_finish_time, key=lambda task: (task.finished_at, task.seq))
33
+ if with_finish_time
34
+ else max(completed, key=lambda task: task.seq, default=None)
35
+ )
36
+ available_rows = HUMAN_STATUS_TASK_LIMIT - (done is not None)
37
+ selected = (escalated + active + queued)[:available_rows]
38
+ if done is not None:
39
+ selected.append(done)
40
+ return tuple(selected)