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,457 @@
1
+ """Builds one prompt directory and runs its resumable headless turns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from datetime import timedelta
9
+ from typing import TYPE_CHECKING, TypedDict
10
+
11
+ from shikiru.agents.contracts import (
12
+ PROJECT_ENV_VAR,
13
+ ROLE_ENV_VAR,
14
+ RUN_DIR_ENV_VAR,
15
+ SLOT_ENV_VAR,
16
+ TASK_ENV_VAR,
17
+ FailureKind,
18
+ TurnFailed,
19
+ role_dir,
20
+ )
21
+ from shikiru.agents.contracts import (
22
+ TurnSpec as AgentTurnSpec,
23
+ )
24
+ from shikiru.domain.events import EventKind
25
+ from shikiru.domain.feedback import FeedbackContractError, FeedbackInput, check_feedback_report
26
+ from shikiru.domain.recovery import Completed, Failed, ProtocolError, Stalled, TurnResult
27
+ from shikiru.domain.reports import (
28
+ CURRENT_TURN_LINK_NAME,
29
+ FACTS_FILENAME,
30
+ FEEDBACK_FILENAME,
31
+ HELPER_REPORT_FILENAME,
32
+ PROMPT_FILENAME,
33
+ REPORT_FILENAME,
34
+ REPORT_JSON_INDENT,
35
+ REPORT_KIND_FILENAME,
36
+ REPORT_SCHEMA_FILENAME,
37
+ schema_for,
38
+ )
39
+ from shikiru.errors import InvariantError, ShikiruError
40
+ from shikiru.helper.helper import FailureClassification
41
+ from shikiru.helper.models import Unrecoverable
42
+ from shikiru.paths import run_dir, task_root, turns_dir
43
+ from shikiru.private import make_private_dir, write_private_text
44
+ from shikiru.prompts.renderer import OPERATOR_MESSAGES_KEY
45
+ from shikiru.view_log import TURN_DIR_ITERATION_MARKER, TURN_DIR_SEPARATOR
46
+ from shikiru.workspace.reports import InvalidTurnReportError, load_turn_report
47
+
48
+ if TYPE_CHECKING:
49
+ from collections.abc import Callable, Mapping
50
+ from pathlib import Path
51
+
52
+ from shikiru.config.settings import Settings
53
+ from shikiru.domain.reports import Report, ReportKind
54
+ from shikiru.domain.tasks import Role, Step, Task
55
+ from shikiru.engine.agents import AgentPool
56
+ from shikiru.engine.monitor import TurnMonitor
57
+ from shikiru.engine.sessions import SessionManager, SessionSelection
58
+ from shikiru.helper.helper import Helper
59
+ from shikiru.prompts.renderer import PromptName, PromptRenderer
60
+ from shikiru.store.events import EventLog
61
+ from shikiru.store.runs import RunStore
62
+ from shikiru.store.tasks import TaskStore
63
+ from shikiru.workspace.messages import OperatorMessages
64
+
65
+ NUDGE_LIMIT = 2
66
+ CLASSIFY_CONFIDENCE = 0.8
67
+ _TURN_NUMBER_WIDTH = 2
68
+ TURN_PROMPT = "Read {prompt_path} and follow it exactly."
69
+ _NUDGE_MESSAGE = (
70
+ "Your turn ended without a valid report. Submit it with `shikiru report` as described "
71
+ "at the end of {prompt_path}."
72
+ )
73
+ _LOGGER = logging.getLogger(__name__)
74
+
75
+
76
+ class TurnHeld(Exception):
77
+ """A task hold was set before the turn's agent process could start."""
78
+
79
+
80
+ class _HelperCall(TypedDict):
81
+ cwd: Path
82
+ timeout: timedelta
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class TurnRequest:
87
+ """Prompt details for one workflow step."""
88
+
89
+ step: Step
90
+ iteration: int
91
+ kind: ReportKind
92
+ template: PromptName
93
+ context: Mapping[str, object]
94
+ facts: str
95
+ feedback: FeedbackInput | None = None
96
+
97
+
98
+ def read_turn_report(turn_dir: Path) -> Report | None:
99
+ """Read `turn_dir`'s submitted report or Helper rebuild; invalid files raise explicitly."""
100
+ loaded = load_turn_report(turn_dir)
101
+ return loaded.report if loaded is not None else None
102
+
103
+
104
+ def turn_environment(
105
+ task: Task, role: Role, run_dir_path: Path, project_name: str
106
+ ) -> dict[str, str]:
107
+ """Return the environment used by every agent process for `task`'s `role`."""
108
+ if task.slot is None:
109
+ message = f"{task.id} has no assigned slot; a turn needs a dispatched task"
110
+ raise InvariantError(message)
111
+ return session_environment(task, role, run_dir_path, project_name)
112
+
113
+
114
+ def session_environment(
115
+ task: Task, role: Role, run_dir_path: Path, project_name: str
116
+ ) -> dict[str, str]:
117
+ """Return the environment of `task`'s `role` process, with the slot only when it has one."""
118
+ environment = {
119
+ RUN_DIR_ENV_VAR: str(run_dir_path),
120
+ ROLE_ENV_VAR: role.value,
121
+ PROJECT_ENV_VAR: project_name,
122
+ TASK_ENV_VAR: task.id,
123
+ }
124
+ if task.slot is not None:
125
+ environment[SLOT_ENV_VAR] = str(task.slot)
126
+ return environment
127
+
128
+
129
+ class TurnRunner:
130
+ """Create turn files, run a process per attempt, and resolve reports and failures."""
131
+
132
+ def __init__(
133
+ self,
134
+ *,
135
+ agents: AgentPool,
136
+ sessions: SessionManager,
137
+ monitor: TurnMonitor,
138
+ helper: Helper,
139
+ renderer: PromptRenderer,
140
+ messages: OperatorMessages,
141
+ events: EventLog,
142
+ runs: RunStore,
143
+ tasks: TaskStore,
144
+ project_root: Path,
145
+ settings: Settings,
146
+ ) -> None:
147
+ self._agents = agents
148
+ self._sessions = sessions
149
+ self._monitor = monitor
150
+ self._helper = helper
151
+ self._renderer = renderer
152
+ self._messages = messages
153
+ self._events = events
154
+ self._runs = runs
155
+ self._tasks = tasks
156
+ self._project_root = project_root
157
+ self._settings = settings
158
+
159
+ async def run(
160
+ self,
161
+ task: Task,
162
+ role: Role,
163
+ selection: SessionSelection,
164
+ request: TurnRequest,
165
+ *,
166
+ on_start: Callable[[Path], None] | None = None,
167
+ on_nudge: Callable[[], None],
168
+ ) -> tuple[TurnResult, Path, SessionSelection]:
169
+ """Run one workflow turn and any resumed nudge turns to a normalized result.
170
+
171
+ Operator messages delivered into the turn return to the queue unless it completes.
172
+ Raises `TurnHeld`, before any agent process starts, when the task is held.
173
+ """
174
+ turn_dir = self._prepare_turn_dir(
175
+ run_dir(self._project_root, task.id), task.id, role, request
176
+ )
177
+ if on_start is not None:
178
+ on_start(turn_dir)
179
+ completed = False
180
+ try:
181
+ result, selection = await self._attempts(
182
+ task, role, selection, request, turn_dir, on_nudge=on_nudge
183
+ )
184
+ completed = isinstance(result, Completed)
185
+ finally:
186
+ if not completed:
187
+ self._messages.requeue(task.id, role, turn_dir)
188
+ return result, turn_dir, selection
189
+
190
+ async def _attempts(
191
+ self,
192
+ task: Task,
193
+ role: Role,
194
+ selection: SessionSelection,
195
+ request: TurnRequest,
196
+ turn_dir: Path,
197
+ *,
198
+ on_nudge: Callable[[], None],
199
+ ) -> tuple[TurnResult, SessionSelection]:
200
+ task_run_dir = run_dir(self._project_root, task.id)
201
+ task_root_dir = task_root(self._project_root, task.id)
202
+ prompt_path = turn_dir / PROMPT_FILENAME
203
+ prompt = TURN_PROMPT.format(prompt_path=prompt_path)
204
+ nudges = 0
205
+ last_message = ""
206
+ while True:
207
+ if self._tasks.require(task.id).held:
208
+ raise TurnHeld
209
+ if nudges:
210
+ on_nudge()
211
+ agent = self._agents.get(selection.rung.agent)
212
+ turn_spec = AgentTurnSpec(
213
+ rung=selection.rung,
214
+ role=role.value,
215
+ prompt=prompt,
216
+ cwd=task_root_dir,
217
+ run_dir=task_run_dir,
218
+ turn_dir=turn_dir,
219
+ add_dirs=(),
220
+ env=turn_environment(task, role, task_run_dir, self._settings.project.name),
221
+ session_id=selection.session_id,
222
+ usage=selection.run.usage,
223
+ on_process=self._monitor.process_recorder(task_run_dir, role),
224
+ )
225
+ turn = await agent.start_turn(turn_spec)
226
+ terminal = await self._monitor.run(
227
+ turn,
228
+ agent=agent,
229
+ task=task,
230
+ role=role,
231
+ rung=selection.rung,
232
+ run_id=selection.run.id,
233
+ turn_dir=turn_dir,
234
+ run_dir=task_run_dir,
235
+ )
236
+ if isinstance(terminal, TurnFailed):
237
+ return await self._classify_unknown(terminal, task, selection), selection
238
+ if isinstance(terminal, Stalled | ProtocolError):
239
+ return terminal, selection
240
+ last_message = terminal.last_message
241
+ try:
242
+ report = read_turn_report(turn_dir)
243
+ _require_feedback_contract(request, report)
244
+ except InvalidTurnReportError, FeedbackContractError:
245
+ _LOGGER.exception("invalid report in completed turn %s", turn_dir)
246
+ report = None
247
+ if report is not None:
248
+ return Completed(report=report), selection
249
+ if nudges >= NUDGE_LIMIT:
250
+ rebuilt = await self._rebuild(task, selection, turn_dir, request, last_message)
251
+ return rebuilt, selection
252
+ nudges += 1
253
+ prompt = _NUDGE_MESSAGE.format(prompt_path=prompt_path)
254
+ selection = self._sessions.session_for(task, role)
255
+
256
+ def completed_turn(
257
+ self, task_id: str, role: Role, request: TurnRequest
258
+ ) -> tuple[Completed, Path] | None:
259
+ """Return the report of `request`'s latest turn when it holds a valid one.
260
+
261
+ A restarted engine re-runs the step whose turn it lost. When that turn is the task's
262
+ latest, still `role`'s current turn, and already submitted a valid report, the report is
263
+ consumed as is instead of starting another turn.
264
+ """
265
+ turns_root = turns_dir(self._project_root, task_id)
266
+ if not turns_root.is_dir():
267
+ return None
268
+ turn_dir = turns_root / _turn_dir_name(_turn_count(turns_root), request)
269
+ link = role_dir(run_dir(self._project_root, task_id), role.value) / CURRENT_TURN_LINK_NAME
270
+ if not link.is_symlink() or link.readlink() != turn_dir:
271
+ return None
272
+ if not _turn_matches_request(turn_dir, request):
273
+ return None
274
+ try:
275
+ report = read_turn_report(turn_dir)
276
+ if report is not None:
277
+ _require_feedback_contract(request, report)
278
+ except InvalidTurnReportError, FeedbackContractError:
279
+ _LOGGER.exception("invalid report in interrupted turn %s", turn_dir)
280
+ return None
281
+ if report is None:
282
+ return None
283
+ return Completed(report=report), turn_dir
284
+
285
+ def _prepare_turn_dir(
286
+ self, task_run_dir: Path, task_id: str, role: Role, request: TurnRequest
287
+ ) -> Path:
288
+ turns_root = turns_dir(self._project_root, task_id)
289
+ make_private_dir(turns_root)
290
+ turn_dir = turns_root / _turn_dir_name(_turn_count(turns_root) + 1, request)
291
+ make_private_dir(turn_dir)
292
+ self._requeue_unreported(task_run_dir, task_id, role)
293
+ (turn_dir / REPORT_FILENAME).unlink(missing_ok=True)
294
+ (turn_dir / HELPER_REPORT_FILENAME).unlink(missing_ok=True)
295
+ self._point_current_turn(task_run_dir, role, turn_dir)
296
+ delivered = self._messages.deliver(task_id, role, turn_dir)
297
+ context = {**request.context, OPERATOR_MESSAGES_KEY: [str(path) for path in delivered]}
298
+ write_private_text(
299
+ turn_dir / PROMPT_FILENAME, self._renderer.render(request.template, context)
300
+ )
301
+ write_private_text(
302
+ turn_dir / REPORT_SCHEMA_FILENAME,
303
+ json.dumps(schema_for(request.kind), indent=REPORT_JSON_INDENT),
304
+ )
305
+ write_private_text(turn_dir / REPORT_KIND_FILENAME, request.kind.value)
306
+ if request.facts:
307
+ write_private_text(turn_dir / FACTS_FILENAME, request.facts)
308
+ if request.feedback is not None:
309
+ write_private_text(
310
+ turn_dir / FEEDBACK_FILENAME,
311
+ request.feedback.model_dump_json(indent=REPORT_JSON_INDENT),
312
+ )
313
+ return turn_dir
314
+
315
+ def _requeue_unreported(self, task_run_dir: Path, task_id: str, role: Role) -> None:
316
+ link = role_dir(task_run_dir, role.value) / CURRENT_TURN_LINK_NAME
317
+ if not link.is_symlink():
318
+ return
319
+ previous = link.readlink()
320
+ reported = any(
321
+ (previous / name).exists() for name in (REPORT_FILENAME, HELPER_REPORT_FILENAME)
322
+ )
323
+ if not reported:
324
+ self._messages.requeue(task_id, role, previous)
325
+
326
+ @staticmethod
327
+ def _point_current_turn(task_run_dir: Path, role: Role, turn_dir: Path) -> None:
328
+ link = role_dir(task_run_dir, role.value) / CURRENT_TURN_LINK_NAME
329
+ make_private_dir(link.parent)
330
+ if link.is_symlink() or link.exists():
331
+ link.unlink()
332
+ link.symlink_to(turn_dir)
333
+
334
+ def _helper_call(self, task: Task) -> _HelperCall:
335
+ return _HelperCall(
336
+ cwd=task_root(self._project_root, task.id),
337
+ timeout=timedelta(seconds=self._settings.orchestration.timeouts.helper),
338
+ )
339
+
340
+ async def _classify_unknown(
341
+ self, failure: TurnFailed, task: Task, selection: SessionSelection
342
+ ) -> Failed:
343
+ if failure.kind is not FailureKind.UNKNOWN:
344
+ return Failed(failure.kind, failure.resets_at, failure.message)
345
+ try:
346
+ classification = await self._helper.classify_failure(
347
+ failure.message, **self._helper_call(task)
348
+ )
349
+ except (ShikiruError, TimeoutError, OSError) as error:
350
+ _LOGGER.exception("failure classification failed for %s", task.id)
351
+ self._events.append(
352
+ EventKind.HELPER_ACTION,
353
+ task_id=task.id,
354
+ message=f"{task.id} failure classification failed",
355
+ data={"role": selection.run.role.value, "error": str(error)},
356
+ )
357
+ return Failed(FailureKind.OTHER, None, failure.message, unclassified=True)
358
+ if isinstance(classification, FailureClassification):
359
+ accepted = (
360
+ classification.confidence >= CLASSIFY_CONFIDENCE
361
+ and classification.kind is not FailureKind.UNKNOWN
362
+ )
363
+ self._events.append(
364
+ EventKind.HELPER_ACTION,
365
+ task_id=task.id,
366
+ message=f"{task.id} failure classified as {classification.kind.value}",
367
+ data={
368
+ "role": selection.run.role.value,
369
+ "kind": classification.kind.value,
370
+ "confidence": classification.confidence,
371
+ "accepted": accepted,
372
+ },
373
+ )
374
+ if accepted:
375
+ return Failed(classification.kind, classification.resets_at, failure.message)
376
+ else:
377
+ self._events.append(
378
+ EventKind.HELPER_ACTION,
379
+ task_id=task.id,
380
+ message=f"{task.id} failure could not be classified",
381
+ data={"role": selection.run.role.value, "reason": classification.unrecoverable},
382
+ )
383
+ return Failed(FailureKind.OTHER, None, failure.message, unclassified=True)
384
+
385
+ async def _rebuild(
386
+ self,
387
+ task: Task,
388
+ selection: SessionSelection,
389
+ turn_dir: Path,
390
+ request: TurnRequest,
391
+ last_message: str,
392
+ ) -> TurnResult:
393
+ try:
394
+ report = await self._helper.rebuild_report(
395
+ request.kind, last_message, request.facts, **self._helper_call(task)
396
+ )
397
+ except (ShikiruError, TimeoutError, OSError) as error:
398
+ _LOGGER.exception("report rebuild failed for %s", task.id)
399
+ self._events.append(
400
+ EventKind.HELPER_ACTION,
401
+ task_id=task.id,
402
+ message=f"{task.id} report rebuild failed",
403
+ data={"role": selection.run.role.value, "error": str(error)},
404
+ )
405
+ return ProtocolError()
406
+ if isinstance(report, Unrecoverable):
407
+ self._events.append(
408
+ EventKind.HELPER_ACTION,
409
+ task_id=task.id,
410
+ message=f"{task.id} report could not be rebuilt",
411
+ data={"role": selection.run.role.value, "reason": report.unrecoverable},
412
+ )
413
+ return ProtocolError()
414
+ if request.feedback is not None:
415
+ try:
416
+ check_feedback_report(request.feedback, report)
417
+ except FeedbackContractError as error:
418
+ _LOGGER.warning(
419
+ "rebuilt report for %s breaks the feedback contract: %s", task.id, error
420
+ )
421
+ return ProtocolError()
422
+ write_private_text(
423
+ turn_dir / HELPER_REPORT_FILENAME, report.model_dump_json(indent=REPORT_JSON_INDENT)
424
+ )
425
+ return Completed(report=report, rebuilt=True)
426
+
427
+
428
+ def _turn_matches_request(turn_dir: Path, request: TurnRequest) -> bool:
429
+ kind_path = turn_dir / REPORT_KIND_FILENAME
430
+ if not kind_path.is_file() or kind_path.read_text().strip() != request.kind.value:
431
+ return False
432
+ feedback_path = turn_dir / FEEDBACK_FILENAME
433
+ if request.feedback is None:
434
+ return not feedback_path.exists()
435
+ if not feedback_path.is_file():
436
+ return False
437
+ try:
438
+ recorded = FeedbackInput.model_validate_json(feedback_path.read_text())
439
+ except ValueError:
440
+ return False
441
+ return recorded == request.feedback
442
+
443
+
444
+ def _require_feedback_contract(request: TurnRequest, report: Report | None) -> None:
445
+ if request.feedback is not None and report is not None:
446
+ check_feedback_report(request.feedback, report)
447
+
448
+
449
+ def _turn_count(turns_root: Path) -> int:
450
+ return sum(1 for entry in turns_root.iterdir() if entry.is_dir())
451
+
452
+
453
+ def _turn_dir_name(number: int, request: TurnRequest) -> str:
454
+ return (
455
+ f"{number:0{_TURN_NUMBER_WIDTH}d}{TURN_DIR_SEPARATOR}{request.step.value}"
456
+ f"{TURN_DIR_ITERATION_MARKER}{request.iteration}"
457
+ )
shikiru/errors.py ADDED
@@ -0,0 +1,144 @@
1
+ """The Shikiru error hierarchy and its exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ EXIT_GENERIC_ERROR = 1
12
+ EXIT_USAGE_ERROR = 2
13
+ EXIT_NOT_INITIALISED = 3
14
+ EXIT_COMMAND_REJECTED = 4
15
+
16
+
17
+ class ShikiruError(Exception):
18
+ """Base class for all errors the CLI maps to a process exit code."""
19
+
20
+ exit_code: int = EXIT_GENERIC_ERROR
21
+
22
+
23
+ class InvariantError(ShikiruError):
24
+ """An invariant the engine relies on does not hold: a logic bug, not an environmental fault."""
25
+
26
+
27
+ class UsageError(ShikiruError):
28
+ """The operator invoked a command incorrectly."""
29
+
30
+ exit_code = EXIT_USAGE_ERROR
31
+
32
+
33
+ class NotInitialisedError(ShikiruError):
34
+ """No `.shikiru/` project was found where one was required."""
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ *,
40
+ project_root: Path | None = None,
41
+ missing_path: Path | None = None,
42
+ ) -> None:
43
+ """Record the missing project location when the CLI can identify it."""
44
+ super().__init__(message)
45
+ self.project_root = project_root
46
+ self.missing_path = missing_path
47
+
48
+ exit_code = EXIT_NOT_INITIALISED
49
+
50
+
51
+ class CommandRejectedError(ShikiruError):
52
+ """The engine rejected an operator command."""
53
+
54
+ exit_code = EXIT_COMMAND_REJECTED
55
+
56
+
57
+ class StaleTaskBranchError(ShikiruError):
58
+ """A task branch left by an earlier state already exists and is not at the task's base."""
59
+
60
+
61
+ class ConfigError(ShikiruError):
62
+ """Project or default configuration is missing or invalid."""
63
+
64
+
65
+ class ProjectNotRepositoryRootError(ConfigError):
66
+ """The selected project directory is outside or below a Git repository root."""
67
+
68
+ def __init__(
69
+ self,
70
+ message: str,
71
+ *,
72
+ project_root: Path,
73
+ repository_root: Path | None,
74
+ ) -> None:
75
+ """Record both the selected directory and an enclosing repository, when available."""
76
+ super().__init__(message)
77
+ self.project_root = project_root
78
+ self.repository_root = repository_root
79
+
80
+
81
+ class RepositoryHasNoCommitsError(ConfigError):
82
+ """The selected Git repository does not have a commit at HEAD."""
83
+
84
+ def __init__(self, message: str, *, project_root: Path) -> None:
85
+ """Record the repository whose first commit is required."""
86
+ super().__init__(message)
87
+ self.project_root = project_root
88
+
89
+
90
+ class HeadlessOutputError(ShikiruError):
91
+ """An agent's headless response did not contain valid structured output."""
92
+
93
+
94
+ class DatabaseError(ShikiruError):
95
+ """The state database could not be opened, migrated or queried."""
96
+
97
+
98
+ class CommandFailed(ShikiruError):
99
+ """A subprocess run through `process.SubprocessRunner` exited non-zero."""
100
+
101
+ def __init__(self, argv: Sequence[str], returncode: int, stderr: str) -> None:
102
+ """Record the failed command's `argv`, exit code and captured stderr."""
103
+ argv_tuple = tuple(argv)
104
+ super().__init__(f"command {argv_tuple} exited {returncode}: {stderr}")
105
+ self.argv = argv_tuple
106
+ self.returncode = returncode
107
+ self.stderr = stderr
108
+
109
+
110
+ class CommandTimeoutError(ShikiruError):
111
+ """A subprocess run through `process.SubprocessRunner` was killed after its timeout elapsed."""
112
+
113
+ def __init__(self, argv: Sequence[str], timeout: float) -> None:
114
+ """Record the timed-out command's `argv` and its `timeout` in seconds."""
115
+ argv_tuple = tuple(argv)
116
+ super().__init__(f"command {argv_tuple} timed out after {timeout}s")
117
+ self.argv = argv_tuple
118
+ self.timeout = timeout
119
+
120
+
121
+ PROCESS_FAILURES = (CommandFailed, CommandTimeoutError)
122
+ """A subprocess call that exited non-zero or timed out."""
123
+
124
+
125
+ class DetachedProcessError(ShikiruError):
126
+ """A detached process could not be launched."""
127
+
128
+ def __init__(self, argv: Sequence[str], error: OSError) -> None:
129
+ """Record the detached command and operating-system error."""
130
+ argv_tuple = tuple(argv)
131
+ super().__init__(f"could not launch detached command {argv_tuple}: {error}")
132
+ self.argv = argv_tuple
133
+ self.error = error
134
+
135
+
136
+ class ProcessReplacementError(ShikiruError):
137
+ """A process could not replace the current process."""
138
+
139
+ def __init__(self, argv: Sequence[str], error: OSError) -> None:
140
+ """Record the replacement command and operating-system error."""
141
+ argv_tuple = tuple(argv)
142
+ super().__init__(f"could not replace process with {argv_tuple}: {error}")
143
+ self.argv = argv_tuple
144
+ self.error = error
@@ -0,0 +1,3 @@
1
+ """The Helper (Joshu): ordered headless calls that rebuild reports and classify failures."""
2
+
3
+ from __future__ import annotations