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
shikiru/process.py ADDED
@@ -0,0 +1,535 @@
1
+ """The only subprocess gateway in Shikiru: argv lists, timeouts, typed errors, async."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import errno
7
+ import os
8
+ import shutil
9
+ import signal
10
+ import subprocess
11
+ import threading
12
+ from contextlib import suppress
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any, NoReturn, cast
15
+
16
+ import psutil
17
+
18
+ from shikiru.errors import (
19
+ CommandFailed,
20
+ CommandTimeoutError,
21
+ DetachedProcessError,
22
+ ProcessReplacementError,
23
+ )
24
+ from shikiru.private import PRIVATE_FILE_MODE
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
28
+ from pathlib import Path
29
+
30
+ TURN_STOP_GRACE = 10.0
31
+ STDERR_DRAIN_TIMEOUT = 2.0
32
+ STDOUT_DRAIN_TIMEOUT = 2.0
33
+ _STDERR_READ_SIZE = 65536
34
+ _STDOUT_READ_SIZE = 65536
35
+ _TERMINAL_READ_SIZE = 65536
36
+ _STDERR_TAIL_CHARS = 4000
37
+ STDERR_KEPT_BYTES = 1024 * 1024
38
+ START_TIME_TOLERANCE_SECONDS = 10.0
39
+ _NO_SIGNAL = cast("signal.Signals", 0)
40
+ _SHELL = "/bin/sh"
41
+ _PATH_VARIABLE = "PATH"
42
+ _GATE_SCRIPT = 'IFS= read -r _ || exit 1; exec "$@" </dev/null'
43
+ _GATE_ARG0 = "shikiru-gate"
44
+ _GATE_RELEASE = b"\n"
45
+ _ABORT_GRACE = 0.0
46
+
47
+
48
+ def ignore_started(_pid: int) -> None:
49
+ """Accept the id of a just-spawned process and do nothing with it."""
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ProcessResult:
54
+ """The outcome of one process run through `SubprocessRunner`."""
55
+
56
+ argv: tuple[str, ...]
57
+ returncode: int
58
+ stdout: str
59
+ stderr: str
60
+
61
+
62
+ def _killpg(pgid: int, signum: signal.Signals) -> bool:
63
+ """Signal process group `pgid`; return whether it was sent.
64
+
65
+ A missing group, or on macOS a group of only zombies (a permission error), has nothing left
66
+ to signal.
67
+ """
68
+ try:
69
+ os.killpg(pgid, signum)
70
+ except ProcessLookupError, PermissionError:
71
+ return False
72
+ return True
73
+
74
+
75
+ async def _end_process(process: asyncio.subprocess.Process, *, grace: float | None) -> None:
76
+ """End the process group `process` leads: SIGTERM and `grace` seconds, then SIGKILL.
77
+
78
+ Without a `grace` the group is SIGKILLed at once. It is always SIGKILLed once its leader
79
+ has exited, so no descendant outlives it.
80
+ """
81
+ if process.returncode is None and grace is not None:
82
+ _killpg(process.pid, signal.SIGTERM)
83
+ with suppress(TimeoutError):
84
+ await asyncio.wait_for(process.wait(), timeout=grace)
85
+ if process.returncode is None:
86
+ _killpg(process.pid, signal.SIGKILL)
87
+ await process.wait()
88
+ _killpg(process.pid, signal.SIGKILL)
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class ProcessIdentity:
93
+ """A process id with its start time, which together never match a reused pid.
94
+
95
+ A process group whose leader had already exited when it was recorded has no start time.
96
+ """
97
+
98
+ pid: int
99
+ started_at: float | None
100
+
101
+ def matches(self, live: ProcessIdentity | None) -> bool:
102
+ """Return whether `live` is this very process, allowing for a shifted start time.
103
+
104
+ Start times derive from the boot time on Linux and move when the clock is stepped, so
105
+ they match within `START_TIME_TOLERANCE_SECONDS`. A reused pid starts a process later
106
+ than that, since no pid is reissued so quickly.
107
+ """
108
+ if live is None or live.pid != self.pid:
109
+ return False
110
+ if self.started_at is None or live.started_at is None:
111
+ return self.started_at is live.started_at
112
+ return abs(live.started_at - self.started_at) <= START_TIME_TOLERANCE_SECONDS
113
+
114
+
115
+ def identify_pid(pid: int) -> ProcessIdentity | None:
116
+ """Return the identity of process `pid`, or `None` when no inspectable process runs.
117
+
118
+ An exited (or zombie) process has no identity, and neither has one the caller may not
119
+ inspect, since every process this engine starts is its own.
120
+ """
121
+ try:
122
+ process = psutil.Process(pid)
123
+ if process.status() == psutil.STATUS_ZOMBIE:
124
+ return None
125
+ return ProcessIdentity(pid, process.create_time())
126
+ except psutil.NoSuchProcess, psutil.AccessDenied:
127
+ return None
128
+
129
+
130
+ class SpawnedProcess:
131
+ """A long-running child process with closed stdin and streamed stdout."""
132
+
133
+ def __init__(self, process: asyncio.subprocess.Process) -> None:
134
+ self._process = process
135
+ self._stderr_tail = bytearray()
136
+ self._stderr_task = asyncio.create_task(self._read_stderr())
137
+ self._stdout_read = False
138
+ self._group_swept = False
139
+ self._stop_lock = asyncio.Lock()
140
+ self._stderr = ""
141
+ self._stderr_bytes = b""
142
+
143
+ @property
144
+ def pid(self) -> int:
145
+ """Return this child process's operating system id."""
146
+ return self._process.pid
147
+
148
+ @property
149
+ def returncode(self) -> int | None:
150
+ """Return the exit status when this process has exited."""
151
+ return self._process.returncode
152
+
153
+ @property
154
+ def stderr(self) -> str:
155
+ """Return the last `STDERR_KEPT_BYTES` of stderr captured after `wait()` completes."""
156
+ return self._stderr
157
+
158
+ @property
159
+ def stderr_bytes(self) -> bytes:
160
+ """Return the exact last `STDERR_KEPT_BYTES` of stderr after `wait()` completes."""
161
+ return self._stderr_bytes
162
+
163
+ @property
164
+ def stderr_tail(self) -> str:
165
+ """Return a bounded stderr tail for failure messages."""
166
+ return self._stderr[-_STDERR_TAIL_CHARS:]
167
+
168
+ async def stdout_lines(self) -> AsyncIterator[bytes]:
169
+ """Yield stdout lines as original bytes, including line endings, exactly once.
170
+
171
+ Once the leader has exited, `STDOUT_DRAIN_TIMEOUT` seconds later its process group is
172
+ SIGKILLed, so a descendant holding the pipe cannot keep the stream open.
173
+ """
174
+ if self._stdout_read:
175
+ message = "stdout_lines() can only be consumed once"
176
+ raise RuntimeError(message)
177
+ self._stdout_read = True
178
+ if self._process.stdout is None:
179
+ message = "spawned process has no stdout pipe"
180
+ raise RuntimeError(message)
181
+ pending = bytearray()
182
+ sweeper = asyncio.create_task(self._sweep_group_after_exit())
183
+ try:
184
+ while chunk := await self._process.stdout.read(_STDOUT_READ_SIZE):
185
+ pending.extend(chunk)
186
+ while (newline_index := pending.find(b"\n")) >= 0:
187
+ line_end = newline_index + 1
188
+ yield bytes(pending[:line_end])
189
+ del pending[:line_end]
190
+ finally:
191
+ sweeper.cancel()
192
+ if pending:
193
+ yield bytes(pending)
194
+
195
+ async def wait(self) -> int:
196
+ """Wait for process exit, end its process group, and capture stderr within a bound."""
197
+ returncode = await self._process.wait()
198
+ self._sweep_group()
199
+ _, pending = await asyncio.wait({self._stderr_task}, timeout=STDERR_DRAIN_TIMEOUT)
200
+ for task in pending:
201
+ task.cancel()
202
+ self._stderr_bytes = bytes(self._stderr_tail)
203
+ self._stderr = self._stderr_bytes.decode(errors="replace")
204
+ return returncode
205
+
206
+ def _sweep_group(self) -> None:
207
+ if not self._group_swept:
208
+ self._group_swept = True
209
+ _killpg(self._process.pid, signal.SIGKILL)
210
+
211
+ async def _sweep_group_after_exit(self) -> None:
212
+ await self._process.wait()
213
+ await asyncio.sleep(STDOUT_DRAIN_TIMEOUT)
214
+ self._sweep_group()
215
+
216
+ async def stop(self, *, grace: float = TURN_STOP_GRACE) -> None:
217
+ """Signal the process group with SIGTERM, then SIGKILL after `grace`, and reap it."""
218
+ async with self._stop_lock:
219
+ await _end_process(self._process, grace=grace)
220
+ await self.wait()
221
+
222
+ async def _read_stderr(self) -> None:
223
+ if self._process.stderr is None:
224
+ message = "spawned process has no stderr pipe"
225
+ raise RuntimeError(message)
226
+ while chunk := await self._process.stderr.read(_STDERR_READ_SIZE):
227
+ self._stderr_tail.extend(chunk)
228
+ del self._stderr_tail[:-STDERR_KEPT_BYTES]
229
+
230
+
231
+ class SpawnedTerminalProcess:
232
+ """A process attached to a private pseudo-terminal."""
233
+
234
+ def __init__(self, process: asyncio.subprocess.Process, master_fd: int) -> None:
235
+ self._process = process
236
+ self._master_fd = master_fd
237
+ os.set_blocking(master_fd, False)
238
+ self._loop = asyncio.get_running_loop()
239
+ self._loop.add_reader(master_fd, self._drain_terminal)
240
+ self._stop_lock = asyncio.Lock()
241
+ self._terminal_closed = False
242
+ self._output = bytearray()
243
+
244
+ @property
245
+ def returncode(self) -> int | None:
246
+ """Return the terminal process's exit code when it has finished."""
247
+ return self._process.returncode
248
+
249
+ @property
250
+ def output(self) -> bytes:
251
+ """Return every byte the terminal has produced so far."""
252
+ return bytes(self._output)
253
+
254
+ @property
255
+ def pid(self) -> int:
256
+ """Return the terminal process's PID."""
257
+ return self._process.pid
258
+
259
+ def write_input(self, value: bytes) -> None:
260
+ """Write input bytes to the terminal attached to this process."""
261
+ os.write(self._master_fd, value)
262
+
263
+ def send_interrupt(self) -> None:
264
+ """Send SIGINT to the terminal process."""
265
+ self._process.send_signal(signal.SIGINT)
266
+
267
+ async def stop(self, *, grace: float = TURN_STOP_GRACE) -> None:
268
+ """Stop the process group and close the private pseudo-terminal."""
269
+ async with self._stop_lock:
270
+ try:
271
+ await _end_process(self._process, grace=grace)
272
+ finally:
273
+ if not self._terminal_closed:
274
+ self._terminal_closed = True
275
+ self._loop.remove_reader(self._master_fd)
276
+ os.close(self._master_fd)
277
+
278
+ def _drain_terminal(self) -> None:
279
+ while True:
280
+ try:
281
+ chunk = os.read(self._master_fd, _TERMINAL_READ_SIZE)
282
+ if not chunk:
283
+ self._loop.remove_reader(self._master_fd)
284
+ return
285
+ self._output.extend(chunk)
286
+ except BlockingIOError:
287
+ return
288
+ except OSError as error:
289
+ if error.errno == errno.EIO:
290
+ self._loop.remove_reader(self._master_fd)
291
+ return
292
+ raise
293
+
294
+
295
+ class SubprocessRunner:
296
+ """Runs argv lists as subprocesses, never through a shell."""
297
+
298
+ async def run(
299
+ self,
300
+ argv: Sequence[str],
301
+ *,
302
+ timeout: float,
303
+ cwd: Path | None = None,
304
+ env: Mapping[str, str] | None = None,
305
+ input_text: str | None = None,
306
+ check: bool = False,
307
+ stop_grace: float | None = None,
308
+ ) -> ProcessResult:
309
+ """Run `argv` and return its result, honouring `timeout`.
310
+
311
+ On expiry or cancellation the process is SIGKILLed, or with a `stop_grace` given SIGTERMed
312
+ and killed only when it outlasts that many seconds. The process leads its own process
313
+ group, which is ended with it so no descendant (a hook, an MCP server) outlives the call.
314
+ """
315
+ argv_tuple = tuple(argv)
316
+ process = await self._start(
317
+ argv_tuple,
318
+ cwd=cwd,
319
+ env=env,
320
+ stdin=asyncio.subprocess.PIPE if input_text is not None else None,
321
+ )
322
+ input_bytes = input_text.encode() if input_text is not None else None
323
+ try:
324
+ try:
325
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
326
+ process.communicate(input_bytes), timeout=timeout
327
+ )
328
+ except TimeoutError:
329
+ await self._end_and_wait(process, grace=stop_grace)
330
+ raise CommandTimeoutError(argv_tuple, timeout) from None
331
+ except asyncio.CancelledError:
332
+ await self._end_and_wait(process, grace=stop_grace)
333
+ raise
334
+ finally:
335
+ self._close_transport(process)
336
+
337
+ result = ProcessResult(
338
+ argv=argv_tuple,
339
+ returncode=cast("int", process.returncode),
340
+ stdout=stdout_bytes.decode(),
341
+ stderr=stderr_bytes.decode(),
342
+ )
343
+ if check and result.returncode != 0:
344
+ raise CommandFailed(argv_tuple, result.returncode, result.stderr)
345
+ return result
346
+
347
+ async def spawn(
348
+ self,
349
+ argv: Sequence[str],
350
+ *,
351
+ cwd: Path | None = None,
352
+ env: Mapping[str, str] | None = None,
353
+ on_started: Callable[[int], None] = ignore_started,
354
+ ) -> SpawnedProcess:
355
+ """Start `argv` with stdin closed and stdout and stderr captured as streams.
356
+
357
+ The process leads its own session and does not run `argv` until `on_started` has
358
+ returned, so the caller can durably record its id first. When the caller dies or
359
+ `on_started` raises before that, `argv` never runs and the process exits or is killed.
360
+ """
361
+ argv_tuple = tuple(argv)
362
+ search_path = (env if env is not None else os.environ).get(_PATH_VARIABLE)
363
+ if shutil.which(argv_tuple[0], path=search_path) is None:
364
+ raise FileNotFoundError(argv_tuple[0])
365
+ gate_read, gate_write = os.pipe()
366
+ try:
367
+ try:
368
+ process = await self._start(
369
+ (_SHELL, "-c", _GATE_SCRIPT, _GATE_ARG0, *argv_tuple),
370
+ cwd=cwd,
371
+ env=env,
372
+ stdin=gate_read,
373
+ )
374
+ finally:
375
+ os.close(gate_read)
376
+ spawned = SpawnedProcess(process)
377
+ try:
378
+ on_started(process.pid)
379
+ os.write(gate_write, _GATE_RELEASE)
380
+ except BaseException:
381
+ await spawned.stop(grace=_ABORT_GRACE)
382
+ raise
383
+ finally:
384
+ os.close(gate_write)
385
+ return spawned
386
+
387
+ async def spawn_terminal(
388
+ self,
389
+ argv: Sequence[str],
390
+ *,
391
+ cwd: Path | None = None,
392
+ env: Mapping[str, str] | None = None,
393
+ ) -> SpawnedTerminalProcess:
394
+ """Start `argv` with a private terminal connected to standard input and output."""
395
+ argv_tuple = tuple(argv)
396
+ master_fd, slave_fd = os.openpty()
397
+ starting = asyncio.ensure_future(
398
+ asyncio.create_subprocess_exec(
399
+ *argv_tuple,
400
+ cwd=cwd,
401
+ env=env,
402
+ stdin=slave_fd,
403
+ stdout=slave_fd,
404
+ stderr=slave_fd,
405
+ start_new_session=True,
406
+ )
407
+ )
408
+ try:
409
+ try:
410
+ process = await asyncio.shield(starting)
411
+ except asyncio.CancelledError:
412
+ process = await starting
413
+ await self._end_and_wait(process, grace=None)
414
+ raise
415
+ except BaseException:
416
+ os.close(master_fd)
417
+ raise
418
+ finally:
419
+ os.close(slave_fd)
420
+ return SpawnedTerminalProcess(process, master_fd)
421
+
422
+ def spawn_detached(
423
+ self,
424
+ argv: Sequence[str],
425
+ *,
426
+ output: Path,
427
+ env: Mapping[str, str] | None = None,
428
+ ) -> int:
429
+ """Start `argv` in a new session and return its process id without waiting.
430
+
431
+ Its stdout and stderr go to `output`, an owner-only file truncated by this start, so a
432
+ process that dies at once leaves its error behind.
433
+ """
434
+ argv_tuple = tuple(argv)
435
+ output_fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_FILE_MODE)
436
+ try:
437
+ try:
438
+ process = subprocess.Popen( # noqa: S603 # keep: gateway executes argv without a shell
439
+ argv_tuple,
440
+ env=dict(env) if env is not None else dict(os.environ),
441
+ stdout=output_fd,
442
+ stderr=output_fd,
443
+ close_fds=False,
444
+ restore_signals=False,
445
+ start_new_session=True,
446
+ )
447
+ except OSError as error:
448
+ raise DetachedProcessError(argv_tuple, error) from error
449
+ threading.Thread(target=process.wait, daemon=True).start()
450
+ return process.pid
451
+ finally:
452
+ os.close(output_fd)
453
+
454
+ def identify(self, pid: int) -> ProcessIdentity | None:
455
+ """Return the identity of process `pid`, or `None` when no inspectable process runs."""
456
+ return identify_pid(pid)
457
+
458
+ def is_running(self, identity: ProcessIdentity) -> bool:
459
+ """Return whether the very process `identity` names still runs."""
460
+ return identity.matches(self.identify(identity.pid))
461
+
462
+ def group_is_running(self, pgid: int) -> bool:
463
+ """Return whether any process of group `pgid` still runs."""
464
+ return _killpg(pgid, _NO_SIGNAL)
465
+
466
+ def signal_group(self, identity: ProcessIdentity, signum: signal.Signals) -> bool:
467
+ """Send `signum` to the process group `identity` leads; return whether it was sent.
468
+
469
+ A group outlives its leader while any member runs, and no pid equal to a live group id is
470
+ reused, so only a pid now held by a different process is refused.
471
+ """
472
+ live = self.identify(identity.pid)
473
+ if live is not None and not identity.matches(live):
474
+ return False
475
+ return _killpg(identity.pid, signum)
476
+
477
+ def replace(self, argv: Sequence[str], *, env: Mapping[str, str] | None = None) -> NoReturn:
478
+ """Replace this process with `argv`, resolving its executable on `PATH`."""
479
+ argv_tuple = tuple(argv)
480
+ executable = shutil.which(argv_tuple[0])
481
+ if executable is None:
482
+ error = FileNotFoundError(argv_tuple[0])
483
+ raise ProcessReplacementError(argv_tuple, error) from error
484
+ try:
485
+ os.execve( # noqa: S606
486
+ executable,
487
+ argv_tuple,
488
+ dict(env) if env is not None else dict(os.environ),
489
+ )
490
+ except OSError as error:
491
+ raise ProcessReplacementError(argv_tuple, error) from error
492
+
493
+ @classmethod
494
+ async def _start(
495
+ cls,
496
+ argv: tuple[str, ...],
497
+ *,
498
+ cwd: Path | None,
499
+ env: Mapping[str, str] | None,
500
+ stdin: int | None,
501
+ ) -> asyncio.subprocess.Process:
502
+ starting = asyncio.ensure_future(
503
+ asyncio.create_subprocess_exec(
504
+ *argv,
505
+ cwd=cwd,
506
+ env=env,
507
+ stdin=stdin,
508
+ stdout=asyncio.subprocess.PIPE,
509
+ stderr=asyncio.subprocess.PIPE,
510
+ start_new_session=True,
511
+ )
512
+ )
513
+ try:
514
+ return await asyncio.shield(starting)
515
+ except asyncio.CancelledError:
516
+ process = await starting
517
+ await cls._end_and_wait(process, grace=None)
518
+ cls._close_transport(process)
519
+ raise
520
+
521
+ @staticmethod
522
+ async def _end_and_wait(process: asyncio.subprocess.Process, *, grace: float | None) -> None:
523
+ cleanup = asyncio.create_task(_end_process(process, grace=grace))
524
+ try:
525
+ await asyncio.shield(cleanup)
526
+ except asyncio.CancelledError:
527
+ await cleanup
528
+
529
+ @staticmethod
530
+ def _close_transport(process: asyncio.subprocess.Process) -> None:
531
+ transport = cast(
532
+ "Any",
533
+ process._transport, # noqa: SLF001 # pyright: ignore[reportAttributeAccessIssue]
534
+ )
535
+ transport.close()
@@ -0,0 +1,3 @@
1
+ """Jinja2 templates for agent turns, packaged and overridable."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,85 @@
1
+ """Renders turn prompts from packaged templates, overridable per project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum, auto
6
+ from typing import TYPE_CHECKING
7
+
8
+ from jinja2 import (
9
+ BaseLoader,
10
+ ChoiceLoader,
11
+ Environment,
12
+ FileSystemLoader,
13
+ PackageLoader,
14
+ StrictUndefined,
15
+ )
16
+
17
+ from shikiru.agents.contracts import shikiru_command
18
+ from shikiru.domain.acceptance import ACCEPTANCE_CRITERIA_HEADING
19
+ from shikiru.domain.fencing import fence
20
+ from shikiru.paths import PROMPT_OVERRIDES_RELATIVE_PATH
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Mapping
24
+ from pathlib import Path
25
+
26
+ TEMPLATES_PACKAGE = "shikiru.prompts"
27
+ TEMPLATES_PACKAGE_PATH = "templates"
28
+ TEMPLATE_FILENAME_SUFFIX = ".md.j2"
29
+
30
+ REPORT_DRAFT_FILENAME = "report.draft.json"
31
+ REPORT_INSTRUCTION = (
32
+ f"Write the report JSON matching `report.schema.json` to `{REPORT_DRAFT_FILENAME}` in this "
33
+ "prompt's directory, never inside the workspace, and run "
34
+ f"`{shikiru_command('report', '--file')} <that file>`. If it exits non-zero, fix it and run "
35
+ "it again. Then end your turn."
36
+ )
37
+ OPERATOR_MESSAGES_KEY = "operator_messages"
38
+ ALREADY_DONE_SENTENCE = "If this work is already done in the workspace, verify it and report."
39
+
40
+
41
+ class PromptName(StrEnum):
42
+ """One packaged template name, also its filename stem."""
43
+
44
+ TRIAGE = auto()
45
+ PLAN = auto()
46
+ IMPLEMENT = auto()
47
+ REWORK = auto()
48
+ CONSULT = auto()
49
+ VERIFY = auto()
50
+ CONFLICTS = auto()
51
+ CONTINUE = auto()
52
+ HANDOFF = auto()
53
+ FEEDBACK_ANALYSIS = auto()
54
+ FEEDBACK_REWORK = auto()
55
+ FEEDBACK_VERIFY = auto()
56
+
57
+
58
+ class PromptRenderer:
59
+ """Renders a `PromptName` template, preferring a project's `.shikiru/prompts/` override."""
60
+
61
+ def __init__(self, project_dir: Path) -> None:
62
+ """Build the Jinja2 environment: project overrides first, packaged templates beneath."""
63
+ loaders: list[BaseLoader] = []
64
+ overrides_dir = project_dir / PROMPT_OVERRIDES_RELATIVE_PATH
65
+ if overrides_dir.is_dir():
66
+ loaders.append(FileSystemLoader(overrides_dir))
67
+ loaders.append(PackageLoader(TEMPLATES_PACKAGE, TEMPLATES_PACKAGE_PATH))
68
+ self._env = Environment( # noqa: S701 -- Markdown prompts, not HTML; autoescape doesn't apply
69
+ loader=ChoiceLoader(loaders),
70
+ undefined=StrictUndefined,
71
+ trim_blocks=True,
72
+ lstrip_blocks=True,
73
+ )
74
+ self._env.filters["fenced"] = fence
75
+
76
+ def render(self, name: PromptName, context: Mapping[str, object]) -> str:
77
+ """Render `name`'s template against `context`, raising on any undefined variable."""
78
+ template = self._env.get_template(f"{name.value}{TEMPLATE_FILENAME_SUFFIX}")
79
+ merged = {
80
+ "report_instruction": REPORT_INSTRUCTION,
81
+ "already_done_sentence": ALREADY_DONE_SENTENCE,
82
+ "acceptance_criteria_heading": ACCEPTANCE_CRITERIA_HEADING,
83
+ **context,
84
+ }
85
+ return template.render(**merged)
@@ -0,0 +1,17 @@
1
+ {% import "_macros.md.j2" as macros %}
2
+ {{ macros.operator_messages_section(operator_messages) }}
3
+ {{ macros.request_section(request) }}
4
+
5
+ {{ macros.trackers_line(trackers) }}
6
+
7
+ Your workspace is `{{ workspace }}`, the current directory: read and change files only there.
8
+ The project checkout it came from is off limits. Stay on the branch checked out there:
9
+ never switch branches or detach `HEAD`, or your commits are not integrated.
10
+
11
+ {{ macros.reports_section(reports) }}
12
+
13
+ {% block body %}{% endblock %}
14
+
15
+ {{ already_done_sentence }}
16
+
17
+ {{ report_instruction }}
@@ -0,0 +1,61 @@
1
+ {% macro request_section(request) -%}
2
+ ## Request
3
+
4
+ {{ request | fenced("text") }}
5
+ {%- endmacro %}
6
+
7
+ {% macro trackers_line(trackers) -%}
8
+ Trackers: {% for path in trackers %}`{{ path }}`{% if not loop.last %}, {% endif %}{% endfor %}
9
+ {%- endmacro %}
10
+
11
+ {% macro reports_section(reports) -%}
12
+ {% if reports %}
13
+ ## Earlier reports
14
+ {% for report in reports %}
15
+ ### {{ report.label }}
16
+
17
+ {{ report.json | fenced("json") }}
18
+ {% endfor %}
19
+ {% endif -%}
20
+ {%- endmacro %}
21
+
22
+ {% macro facts_section(facts) -%}
23
+ ## Facts
24
+
25
+ {{ facts | fenced("text") }}
26
+ {%- endmacro %}
27
+
28
+ {% macro operator_messages_section(paths) -%}
29
+ {% if paths %}
30
+ ## Operator messages — read these first
31
+
32
+ {% for path in paths %}
33
+ - `{{ path }}`
34
+ {% endfor %}
35
+ {% endif -%}
36
+ {%- endmacro %}
37
+
38
+ {% macro consult_body(question, operator_decision) -%}
39
+ You are the Leader, consulted by the Implementer during this task.
40
+ {% if operator_decision is not none %}
41
+
42
+ ## Decision from the Operator
43
+
44
+ {{ operator_decision | fenced("text") }}
45
+
46
+ This is the product owner's decision in answer to an escalation, and it settles the question that
47
+ escalation asked. Use it as the basis of your `answer`, adapted into guidance for the Implementer.
48
+ Do not escalate the same question again unless the decision is unclear or contradicts the request.
49
+ {% endif %}
50
+ {% if question or operator_decision is none %}
51
+
52
+ ## Implementer's question
53
+
54
+ {{ question | fenced("text") }}
55
+ {% endif %}
56
+
57
+ Decide `answer`, with the guidance the Implementer needs to continue, or `escalate` with a
58
+ question and options for the Operator, exactly one of them `recommended`.
59
+ Set `needs_input` on an option that asks the Operator to provide something (a file, a result, access,
60
+ a value): choosing it lets the Operator type the details.
61
+ {%- endmacro %}