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,325 @@
1
+ """Step transitions for the task workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING
8
+
9
+ from shikiru.domain.ladder import (
10
+ REJECTIONS_FIELD,
11
+ move_up,
12
+ pick_rung,
13
+ reset_for_integration_attempt,
14
+ reset_for_new_iteration,
15
+ )
16
+ from shikiru.domain.reports import (
17
+ DECISION_ESCALATE,
18
+ STATUS_COMPLETED,
19
+ ConsultAnswer,
20
+ FeedbackAnalysis,
21
+ ImplementationReport,
22
+ PlanReport,
23
+ TriageReport,
24
+ Verdict,
25
+ )
26
+ from shikiru.domain.tasks import (
27
+ DEFAULT_COMPLEXITY,
28
+ Counters,
29
+ EscalationReason,
30
+ Mode,
31
+ Role,
32
+ Status,
33
+ StatusReason,
34
+ Step,
35
+ Task,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from shikiru.agents.contracts import Rung
40
+ from shikiru.config.settings import ComplexityName, Settings
41
+
42
+ CONSULT_LIMIT = 3
43
+ CONFLICT_ROUNDS = 2
44
+
45
+ Outcome = (
46
+ TriageReport | PlanReport | ImplementationReport | ConsultAnswer | Verdict | FeedbackAnalysis
47
+ )
48
+
49
+ ACTING_ROLE: dict[Step, Role] = {
50
+ Step.TRIAGE: Role.LEADER,
51
+ Step.PLAN: Role.LEADER,
52
+ Step.IMPLEMENT: Role.IMPLEMENTER,
53
+ Step.CONSULT: Role.LEADER,
54
+ Step.VERIFY: Role.LEADER,
55
+ Step.INTEGRATE: Role.IMPLEMENTER,
56
+ }
57
+ """Which role acts at each step: the Implementer only acts in `integrate` on conflicts."""
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Transition:
62
+ """The task's new step/status/iteration/counters/rungs, plus side effects as data.
63
+
64
+ `base` is the task snapshot the decision was computed from; `rebased_onto` reconciles the
65
+ decision with the row as it is when the decision is written.
66
+ """
67
+
68
+ base: Task
69
+ step: Step | None
70
+ status: Status
71
+ iteration: int
72
+ counters: Counters
73
+ rung_index: dict[Role, int]
74
+ set_overrides: dict[Role, Rung] = field(default_factory=dict)
75
+ cleared_overrides: dict[Role, Rung] = field(default_factory=dict)
76
+ complexity: ComplexityName | None = None
77
+ mode: Mode | None = None
78
+ title: str | None = None
79
+ status_reason: StatusReason | None = None
80
+ escalation_reason: EscalationReason | None = None
81
+ repoint_dependents: bool = False
82
+ enqueue_new_tasks: bool = False
83
+
84
+ def rebased_onto(self, current: Task) -> Transition:
85
+ """Keep what changed since `base` and apply only what this decision itself changed.
86
+
87
+ A role whose override differs between `base` and `current` was repinned by the operator
88
+ while the turn ran: its rung index and, for the Implementer, `rejections_on_rung` stay as
89
+ stored, so neither the turn's rejection nor an automatic move undoes the pin. Every other
90
+ iteration, counter and rung index takes the decision's value only where the decision
91
+ changed it relative to `base`.
92
+ """
93
+ repinned = {
94
+ role for role in Role if current.overrides.get(role) != self.base.overrides.get(role)
95
+ }
96
+ changed = {
97
+ name: value
98
+ for name, value in self.counters
99
+ if value != getattr(self.base.counters, name)
100
+ and not (name == REJECTIONS_FIELD and Role.IMPLEMENTER in repinned)
101
+ }
102
+ rung_index = dict(current.rung_index)
103
+ rung_index.update(
104
+ {
105
+ role: index
106
+ for role, index in self.rung_index.items()
107
+ if role not in repinned and index != self.base.rung_index.get(role)
108
+ }
109
+ )
110
+ return dataclasses.replace(
111
+ self,
112
+ iteration=self.iteration
113
+ if self.iteration != self.base.iteration
114
+ else current.iteration,
115
+ counters=current.counters.model_copy(update=changed),
116
+ rung_index=rung_index,
117
+ )
118
+
119
+ @staticmethod
120
+ def stay(task: Task, *, step: Step | None, status: Status = Status.RUNNING) -> Transition:
121
+ """Build a transition that keeps every field of `task` except `step` and `status`."""
122
+ return Transition(
123
+ base=task,
124
+ step=step,
125
+ status=status,
126
+ iteration=task.iteration,
127
+ counters=task.counters,
128
+ rung_index=dict(task.rung_index),
129
+ )
130
+
131
+ @staticmethod
132
+ def escalate(task: Task, reason: EscalationReason, *, step: Step) -> Transition:
133
+ """Build an escalation transition while preserving the task's other state."""
134
+ base = Transition.stay(task, step=step, status=Status.ESCALATED)
135
+ return dataclasses.replace(base, escalation_reason=reason)
136
+
137
+ @staticmethod
138
+ def escalate_current(task: Task, reason: EscalationReason) -> Transition:
139
+ """Escalate at the current step, preserving a missing step for an unstarted task."""
140
+ if task.step is None:
141
+ base = Transition.stay(task, step=None, status=Status.ESCALATED)
142
+ return dataclasses.replace(base, escalation_reason=reason)
143
+ return Transition.escalate(task, reason, step=task.step)
144
+
145
+
146
+ def first_step(task: Task) -> Transition:
147
+ """Compute the transition for a task's very first turn (submit flags).
148
+
149
+ A task with no `mode` starts at `triage`. `submit --mode implement|plan` skips triage
150
+ straight into that step; `implement` also starts numbering from iteration 1, matching the
151
+ turn dirs (`NN-<step>-i<iteration>`) and the `implement` iteration-1 convention.
152
+ """
153
+ if task.mode is None:
154
+ return Transition.stay(task, step=Step.TRIAGE)
155
+ if task.mode is Mode.PLAN:
156
+ return Transition.stay(task, step=Step.PLAN)
157
+ return dataclasses.replace(Transition.stay(task, step=Step.IMPLEMENT), iteration=1)
158
+
159
+
160
+ def task_acting_role(task: Task) -> Role:
161
+ """Return the role for the current step or the task's first scheduled step."""
162
+ step = task.step if task.step is not None else first_step(task).step
163
+ return Role.LEADER if step is None else ACTING_ROLE[step]
164
+
165
+
166
+ def _triaged_complexity(task: Task, outcome: TriageReport) -> ComplexityName | None:
167
+ return outcome.complexity if task.complexity is None else None
168
+
169
+
170
+ def _from_triage(task: Task, outcome: TriageReport, settings: Settings) -> Transition:
171
+ if outcome.mode is Mode.IMPLEMENT:
172
+ base = dataclasses.replace(Transition.stay(task, step=Step.IMPLEMENT), iteration=1)
173
+ else:
174
+ base = Transition.stay(task, step=Step.PLAN)
175
+ complexity = _triaged_complexity(task, outcome)
176
+ implementer = settings.roles.implementer
177
+ if complexity is not None and implementer.ladder_for(complexity) != implementer.ladder_for(
178
+ task.complexity or DEFAULT_COMPLEXITY
179
+ ):
180
+ index = 0
181
+ if task.rung_index.get(Role.IMPLEMENTER, 0) > 0:
182
+ agent = pick_rung(task, Role.IMPLEMENTER, settings).agent
183
+ index = next(
184
+ (
185
+ index
186
+ for index, rung in enumerate(implementer.ladder_for(complexity))
187
+ if rung.agent == agent
188
+ ),
189
+ 0,
190
+ )
191
+ base = dataclasses.replace(base, rung_index={**base.rung_index, Role.IMPLEMENTER: index})
192
+ return dataclasses.replace(
193
+ base,
194
+ complexity=complexity,
195
+ mode=outcome.mode,
196
+ title=outcome.title,
197
+ )
198
+
199
+
200
+ def _from_plan(task: Task) -> Transition:
201
+ base = Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
202
+ return dataclasses.replace(
203
+ base, counters=reset_for_integration_attempt(task.counters), enqueue_new_tasks=True
204
+ )
205
+
206
+
207
+ def _from_implement(task: Task, outcome: ImplementationReport) -> Transition:
208
+ if outcome.status == STATUS_COMPLETED:
209
+ return Transition.stay(task, step=Step.VERIFY)
210
+ return _consult_or_escalate(task, Status.RUNNING)
211
+
212
+
213
+ def _consult_or_escalate(task: Task, status: Status) -> Transition:
214
+ if task.counters.consults >= CONSULT_LIMIT:
215
+ return Transition.escalate(task, EscalationReason.DECISION_REQUIRED, step=Step.CONSULT)
216
+ counters = task.counters.model_copy(update={"consults": task.counters.consults + 1})
217
+ base = Transition.stay(task, step=Step.CONSULT, status=status)
218
+ return dataclasses.replace(base, counters=counters)
219
+
220
+
221
+ def _from_consult(task: Task, outcome: ConsultAnswer) -> Transition:
222
+ if outcome.decision == DECISION_ESCALATE:
223
+ return Transition.escalate(task, EscalationReason.DECISION_REQUIRED, step=Step.CONSULT)
224
+ if task.status is Status.INTEGRATING:
225
+ return Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
226
+ return Transition.stay(task, step=Step.IMPLEMENT)
227
+
228
+
229
+ def _changes_requested(task: Task, settings: Settings) -> Transition:
230
+ if task.iteration >= settings.orchestration.max_iterations:
231
+ return Transition.escalate(task, EscalationReason.ITERATIONS_EXHAUSTED, step=Step.VERIFY)
232
+ rejections = task.counters.rejections_on_rung + 1
233
+ rung_index = dict(task.rung_index)
234
+ cleared_overrides: dict[Role, Rung] = {}
235
+ counters = task.counters.model_copy(update={"rejections_on_rung": rejections})
236
+ if rejections >= settings.roles.implementer.escalate_after:
237
+ move = move_up(task, Role.IMPLEMENTER, settings)
238
+ if move is not None:
239
+ rung_index = move.rung_index
240
+ cleared_overrides = move.cleared_overrides
241
+ counters = move.counters
242
+ counters = reset_for_new_iteration(counters)
243
+ return Transition(
244
+ base=task,
245
+ step=Step.IMPLEMENT,
246
+ status=Status.RUNNING,
247
+ iteration=task.iteration + 1,
248
+ counters=counters,
249
+ rung_index=rung_index,
250
+ cleared_overrides=cleared_overrides,
251
+ )
252
+
253
+
254
+ def _conflict_round(task: Task) -> Transition:
255
+ rounds = task.counters.conflict_rounds + 1
256
+ counters = task.counters.model_copy(update={"conflict_rounds": rounds})
257
+ base = dataclasses.replace(Transition.stay(task, step=Step.INTEGRATE), counters=counters)
258
+ if rounds >= CONFLICT_ROUNDS:
259
+ return dataclasses.replace(
260
+ Transition.escalate(task, EscalationReason.INTEGRATION_CONFLICT, step=Step.INTEGRATE),
261
+ counters=counters,
262
+ )
263
+ return dataclasses.replace(base, status=Status.INTEGRATING)
264
+
265
+
266
+ def _from_verify(task: Task, outcome: Verdict, settings: Settings) -> Transition:
267
+ match outcome.decision:
268
+ case "accept":
269
+ base = Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
270
+ return dataclasses.replace(base, counters=reset_for_integration_attempt(task.counters))
271
+ case "changes_requested":
272
+ return _changes_requested(task, settings)
273
+ case "escalate":
274
+ return Transition.escalate(task, EscalationReason.DECISION_REQUIRED, step=Step.VERIFY)
275
+ case "supersede":
276
+ base = Transition.stay(task, step=None, status=Status.CANCELLED)
277
+ return dataclasses.replace(
278
+ base,
279
+ status_reason=StatusReason.SUPERSEDED,
280
+ repoint_dependents=True,
281
+ enqueue_new_tasks=True,
282
+ )
283
+
284
+
285
+ def _from_integrate(task: Task, outcome: Verdict) -> Transition:
286
+ match outcome.decision:
287
+ case "accept":
288
+ return Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
289
+ case "changes_requested":
290
+ return _conflict_round(task)
291
+ case "escalate":
292
+ return Transition.escalate(
293
+ task, EscalationReason.DECISION_REQUIRED, step=Step.INTEGRATE
294
+ )
295
+ case "supersede":
296
+ message = "a post-rebase conflicts verify cannot supersede the task"
297
+ raise ValueError(message)
298
+
299
+
300
+ def _from_integrate_conflicts(task: Task, outcome: ImplementationReport) -> Transition:
301
+ if outcome.status == STATUS_COMPLETED:
302
+ return Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
303
+ return _consult_or_escalate(task, Status.INTEGRATING)
304
+
305
+
306
+ def next_step(task: Task, outcome: Outcome, settings: Settings) -> Transition:
307
+ """Apply `outcome` to `task`'s current step and return the resulting transition."""
308
+ match task.step, outcome:
309
+ case Step.TRIAGE, TriageReport():
310
+ return _from_triage(task, outcome, settings)
311
+ case Step.PLAN, PlanReport():
312
+ return _from_plan(task)
313
+ case Step.IMPLEMENT, ImplementationReport():
314
+ return _from_implement(task, outcome)
315
+ case Step.CONSULT, ConsultAnswer():
316
+ return _from_consult(task, outcome)
317
+ case Step.VERIFY, Verdict():
318
+ return _from_verify(task, outcome, settings)
319
+ case Step.INTEGRATE, Verdict():
320
+ return _from_integrate(task, outcome)
321
+ case Step.INTEGRATE, ImplementationReport():
322
+ return _from_integrate_conflicts(task, outcome)
323
+ case _:
324
+ message = f"no transition for step {task.step!r} with outcome {outcome!r}"
325
+ raise ValueError(message)
@@ -0,0 +1,174 @@
1
+ """Core task model and its enums."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import StrEnum, auto
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ from shikiru.agents.contracts import Rung
11
+ from shikiru.config.settings import ComplexityName
12
+
13
+ TASK_TITLE_MAX_LENGTH = 60
14
+ TASK_TITLE_ELLIPSIS = "…"
15
+
16
+
17
+ class TaskOrigin(StrEnum):
18
+ """Where a task came from."""
19
+
20
+ OPERATOR = auto()
21
+ LEADER = auto()
22
+
23
+
24
+ class Status(StrEnum):
25
+ """A task's lifecycle status."""
26
+
27
+ QUEUED = auto()
28
+ RUNNING = auto()
29
+ INTEGRATING = auto()
30
+ ESCALATED = auto()
31
+ DONE = auto()
32
+ CANCELLED = auto()
33
+
34
+
35
+ ACTIVE_STATUSES = (Status.RUNNING, Status.INTEGRATING, Status.ESCALATED)
36
+ """The statuses that remain active in a task window."""
37
+
38
+
39
+ class StatusReason(StrEnum):
40
+ """Why a terminal `status` was reached outside the ordinary happy path."""
41
+
42
+ SUPERSEDED = auto()
43
+
44
+
45
+ class Step(StrEnum):
46
+ """The current step of a running task."""
47
+
48
+ TRIAGE = auto()
49
+ PLAN = auto()
50
+ IMPLEMENT = auto()
51
+ CONSULT = auto()
52
+ VERIFY = auto()
53
+ INTEGRATE = auto()
54
+
55
+
56
+ class Mode(StrEnum):
57
+ """What triage decided the request needs."""
58
+
59
+ IMPLEMENT = auto()
60
+ PLAN = auto()
61
+
62
+
63
+ class Hold(StrEnum):
64
+ """A task-scoped hold."""
65
+
66
+ PAUSED = auto()
67
+ INTERVENTION = auto()
68
+
69
+
70
+ class Role(StrEnum):
71
+ """The three agent roles."""
72
+
73
+ LEADER = auto()
74
+ IMPLEMENTER = auto()
75
+ HELPER = auto()
76
+
77
+
78
+ class EscalationReason(StrEnum):
79
+ """Why a task was escalated."""
80
+
81
+ DECISION_REQUIRED = auto()
82
+ ITERATIONS_EXHAUSTED = auto()
83
+ INTEGRATION_CONFLICT = auto()
84
+ INTEGRATION_BLOCKED = auto()
85
+ UNCOMMITTED_WORK = auto()
86
+ OFF_TASK_BRANCH = auto()
87
+ PR_CLOSED = auto()
88
+ AGENT_AUTH = auto()
89
+ AGENT_ERROR = auto()
90
+ AGENT_STUCK = auto()
91
+ AGENT_PROTOCOL = auto()
92
+ LADDER_EXHAUSTED = auto()
93
+ QUOTA_EXHAUSTED = auto()
94
+ DEPENDENCY_CANCELLED = auto()
95
+ ENGINE_ERROR = auto()
96
+
97
+
98
+ class DomainModel(BaseModel):
99
+ """Base for domain models: unknown keys are rejected, fields can't be reassigned.
100
+
101
+ `frozen=True` only blocks assigning a new value to a field; a mutable field's own `list` or
102
+ `dict` can still be mutated in place, which is why callers that need to change one (e.g.
103
+ `Transition.stay`) copy it first rather than relying on the model to be deeply immutable.
104
+ """
105
+
106
+ model_config = ConfigDict(extra="forbid", frozen=True)
107
+
108
+
109
+ class Counters(DomainModel):
110
+ """Persisted per-task counters and their reset rules."""
111
+
112
+ rejections_on_rung: int = 0
113
+ consults: int = 0
114
+ conflict_rounds: int = 0
115
+
116
+
117
+ class RepoState(DomainModel):
118
+ """The task's checkout state (mirrors `tasks.repo`)."""
119
+
120
+ base: str
121
+ base_ref: str
122
+ base_sha: str
123
+ branch: str
124
+
125
+
126
+ DEFAULT_COMPLEXITY: ComplexityName = "normal"
127
+
128
+
129
+ class Task(DomainModel):
130
+ """The task fields the domain rules need (mirrors the `tasks` table)."""
131
+
132
+ id: str
133
+ seq: int
134
+ parent_id: str | None = None
135
+ origin: TaskOrigin
136
+ title: str
137
+ request: str
138
+ mode: Mode | None = None
139
+ complexity: ComplexityName | None = None
140
+ priority: int = 0
141
+ depends_on: list[str] = Field(default_factory=list)
142
+ status: Status
143
+ step: Step | None = None
144
+ status_reason: StatusReason | None = None
145
+ holds: frozenset[Hold] = Field(default_factory=frozenset)
146
+ iteration: int = 0
147
+ counters: Counters = Field(default_factory=Counters)
148
+ rung_index: dict[Role, int] = Field(default_factory=dict)
149
+ overrides: dict[Role, Rung] = Field(default_factory=dict)
150
+ repo: RepoState | None = None
151
+ slot: int | None = None
152
+ window_id: str | None = None
153
+ panes: dict[Role, str] = Field(default_factory=dict)
154
+ wait_reason: str | None = None
155
+ requeued_at: datetime | None = None
156
+ summary: str | None = None
157
+ created_at: datetime
158
+ started_at: datetime | None = None
159
+ finished_at: datetime | None = None
160
+
161
+ @property
162
+ def held(self) -> bool:
163
+ """Whether a task-scoped hold forbids starting the task's next external effect."""
164
+ return bool(self.holds)
165
+
166
+
167
+ def title_from_request(request: str) -> str:
168
+ """Derive a title from the request's first line, cut at a word boundary within the limit."""
169
+ lines = request.splitlines()
170
+ first_line = lines[0] if lines else request
171
+ if len(first_line) <= TASK_TITLE_MAX_LENGTH:
172
+ return first_line
173
+ kept = first_line[: TASK_TITLE_MAX_LENGTH - len(TASK_TITLE_ELLIPSIS)]
174
+ return kept.rsplit(" ", 1)[0] + TASK_TITLE_ELLIPSIS
@@ -0,0 +1,208 @@
1
+ """Pure parsing and classification for the engine's normalized task evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from typing import cast
9
+
10
+ from shikiru.domain.timeline_phrases import GLYPH_ATTENTION, Phrase, Tone, signal_phrase
11
+
12
+ TIMELINE_ACTIVITY_CATEGORY = "activity"
13
+ TIMELINE_ALL_CATEGORY = "all"
14
+ TIMELINE_ENGINE_CATEGORY = "engine"
15
+ TIMELINE_SIGNAL_CATEGORY = "signal"
16
+ TIMELINE_AGENT_CATEGORY = "agent"
17
+ TIMELINE_DECISION_CATEGORY = "decision"
18
+ TIMELINE_TRANSITION_CATEGORY = "transition"
19
+ TIMELINE_TOOL_CATEGORY = "tool"
20
+ TIMELINE_MESSAGE_CATEGORY = "message"
21
+ TIMELINE_USAGE_CATEGORY = "usage"
22
+ TIMELINE_EVIDENCE_CATEGORY = "evidence"
23
+ TIMELINE_DEFAULT_CATEGORY = TIMELINE_ACTIVITY_CATEGORY
24
+ TIMELINE_CATEGORIES = frozenset(
25
+ {
26
+ TIMELINE_ACTIVITY_CATEGORY,
27
+ TIMELINE_ALL_CATEGORY,
28
+ TIMELINE_ENGINE_CATEGORY,
29
+ TIMELINE_SIGNAL_CATEGORY,
30
+ TIMELINE_AGENT_CATEGORY,
31
+ TIMELINE_DECISION_CATEGORY,
32
+ TIMELINE_TRANSITION_CATEGORY,
33
+ TIMELINE_TOOL_CATEGORY,
34
+ TIMELINE_MESSAGE_CATEGORY,
35
+ TIMELINE_USAGE_CATEGORY,
36
+ TIMELINE_EVIDENCE_CATEGORY,
37
+ }
38
+ )
39
+ SIGNAL_TOOL_NAMES = frozenset({"ToolStarted", "ToolFinished"})
40
+ SIGNAL_MESSAGE_NAMES = frozenset({"Message"})
41
+ SIGNAL_USAGE_NAMES = frozenset({"UsageReported", "QuotaReported"})
42
+ SIGNAL_LIFECYCLE_NAMES = frozenset({"SessionStarted", "TurnEnded", "TurnFailed", "Compacted"})
43
+ TIMELINE_DECISION_KINDS = frozenset(
44
+ {
45
+ "verdict",
46
+ "scope_change",
47
+ "escalation",
48
+ "escalation_resolved",
49
+ "task_done",
50
+ "task_cancelled",
51
+ "pr_opened",
52
+ "pr_withdrawn",
53
+ }
54
+ )
55
+ TIMELINE_SOURCE_ENGINE = "engine"
56
+ TIMELINE_SOURCE_AGENT = "agent"
57
+ INCOMPLETE_SIGNAL_SUMMARY = "Incomplete live JSONL row"
58
+ MALFORMED_SIGNAL_SUMMARY = "Malformed normalized signal evidence"
59
+ MISSING_SIGNAL_SUMMARY = "Normalized signal evidence is unavailable"
60
+ MISSING_TIMESTAMP = "recorded timestamp is missing"
61
+ TIMELINE_JSON_INDENT = 2
62
+ _TOOL_FINISHED_SIGNAL = "ToolFinished"
63
+ _OUTCOME_FIELD = "outcome"
64
+ _EXIT_CODE_FIELD = "exit_code"
65
+ _SUCCESS_OUTCOME = "success"
66
+ _FAILURE_OUTCOME = "failure"
67
+ _SUMMARY_SEPARATOR = " · "
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ParsedSignal:
72
+ """One parsed normalized signal row and its recorded evidence identity."""
73
+
74
+ role: str
75
+ turn: str | None
76
+ signal: str | None
77
+ category: str
78
+ timestamp: datetime | None
79
+ timestamp_error: str | None
80
+ summary: str
81
+ phrase: Phrase
82
+ listed: bool
83
+ detail: str
84
+ start_offset: int
85
+ end_offset: int
86
+ incomplete: bool
87
+
88
+
89
+ def parse_signal_row(
90
+ raw: bytes,
91
+ *,
92
+ role: str,
93
+ start_offset: int,
94
+ end_offset: int,
95
+ incomplete: bool,
96
+ ) -> ParsedSignal:
97
+ """Parse one normalized `view.jsonl` row without interpreting provider formats."""
98
+ text = raw.decode("utf-8", errors="replace")
99
+ try:
100
+ raw_data: object = json.loads(text)
101
+ except json.JSONDecodeError:
102
+ summary = INCOMPLETE_SIGNAL_SUMMARY if incomplete else MALFORMED_SIGNAL_SUMMARY
103
+ return ParsedSignal(
104
+ role=role,
105
+ turn=None,
106
+ signal=None,
107
+ category=TIMELINE_EVIDENCE_CATEGORY,
108
+ timestamp=None,
109
+ timestamp_error=MISSING_TIMESTAMP,
110
+ summary=summary,
111
+ phrase=_problem_phrase(summary),
112
+ listed=True,
113
+ detail=text,
114
+ start_offset=start_offset,
115
+ end_offset=end_offset,
116
+ incomplete=incomplete,
117
+ )
118
+ if not isinstance(raw_data, dict):
119
+ return ParsedSignal(
120
+ role=role,
121
+ turn=None,
122
+ signal=None,
123
+ category=TIMELINE_EVIDENCE_CATEGORY,
124
+ timestamp=None,
125
+ timestamp_error=MISSING_TIMESTAMP,
126
+ summary=MALFORMED_SIGNAL_SUMMARY,
127
+ phrase=_problem_phrase(MALFORMED_SIGNAL_SUMMARY),
128
+ listed=True,
129
+ detail=text,
130
+ start_offset=start_offset,
131
+ end_offset=end_offset,
132
+ incomplete=incomplete,
133
+ )
134
+ data = cast("dict[str, object]", raw_data)
135
+ turn = data.get("turn")
136
+ signal = data.get("signal")
137
+ timestamp, timestamp_error = _timestamp(data.get("ts"))
138
+ signal_name = signal if isinstance(signal, str) else None
139
+ summary = signal_name or MISSING_SIGNAL_SUMMARY
140
+ if signal_name == _TOOL_FINISHED_SIGNAL:
141
+ summary = _tool_finished_summary(data)
142
+ phrase = signal_phrase(signal_name, data) if signal_name else _problem_phrase(summary)
143
+ return ParsedSignal(
144
+ role=role,
145
+ turn=turn if isinstance(turn, str) else None,
146
+ signal=signal_name,
147
+ category=signal_category(signal_name),
148
+ timestamp=timestamp,
149
+ timestamp_error=timestamp_error,
150
+ summary=summary,
151
+ phrase=phrase or Phrase(summary),
152
+ listed=phrase is not None,
153
+ detail=json.dumps(data, ensure_ascii=False, sort_keys=True, indent=TIMELINE_JSON_INDENT),
154
+ start_offset=start_offset,
155
+ end_offset=end_offset,
156
+ incomplete=incomplete,
157
+ )
158
+
159
+
160
+ def _problem_phrase(summary: str) -> Phrase:
161
+ return Phrase(summary, GLYPH_ATTENTION, Tone.WARNING)
162
+
163
+
164
+ def _tool_finished_summary(data: dict[str, object]) -> str:
165
+ values = [_TOOL_FINISHED_SIGNAL]
166
+ outcome = data.get(_OUTCOME_FIELD)
167
+ if isinstance(outcome, str) and outcome in (_SUCCESS_OUTCOME, _FAILURE_OUTCOME):
168
+ values.append(outcome)
169
+ exit_code = data.get(_EXIT_CODE_FIELD)
170
+ if type(exit_code) is int:
171
+ values.append(f"exit {exit_code}")
172
+ return _SUMMARY_SEPARATOR.join(values)
173
+
174
+
175
+ def signal_category(signal: str | None) -> str:
176
+ """Classify a normalized signal name for evidence filtering."""
177
+ if signal is None:
178
+ return TIMELINE_EVIDENCE_CATEGORY
179
+ if signal in SIGNAL_TOOL_NAMES:
180
+ return TIMELINE_TOOL_CATEGORY
181
+ if signal in SIGNAL_MESSAGE_NAMES:
182
+ return TIMELINE_MESSAGE_CATEGORY
183
+ if signal in SIGNAL_USAGE_NAMES:
184
+ return TIMELINE_USAGE_CATEGORY
185
+ if signal in SIGNAL_LIFECYCLE_NAMES:
186
+ return TIMELINE_TRANSITION_CATEGORY
187
+ return TIMELINE_SIGNAL_CATEGORY
188
+
189
+
190
+ def event_category(kind: str) -> str:
191
+ """Return the operator-facing category for one persisted engine event."""
192
+ return (
193
+ TIMELINE_DECISION_CATEGORY
194
+ if kind in TIMELINE_DECISION_KINDS
195
+ else TIMELINE_TRANSITION_CATEGORY
196
+ )
197
+
198
+
199
+ def _timestamp(value: object) -> tuple[datetime | None, str | None]:
200
+ if not isinstance(value, str):
201
+ return None, MISSING_TIMESTAMP
202
+ try:
203
+ parsed = datetime.fromisoformat(value)
204
+ except ValueError:
205
+ return None, "recorded timestamp is invalid"
206
+ if parsed.tzinfo is None:
207
+ return None, "recorded timestamp has no UTC offset"
208
+ return parsed.astimezone(UTC), None