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/cli/report.py ADDED
@@ -0,0 +1,156 @@
1
+ """`shikiru report`: validates and atomically stores an agent turn's report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import typer
12
+ from pydantic import ValidationError
13
+
14
+ from shikiru.agents.contracts import ROLE_ENV_VAR, RUN_DIR_ENV_VAR, role_dir
15
+ from shikiru.domain.feedback import FeedbackContractError, FeedbackInput, check_feedback_report
16
+ from shikiru.domain.reports import (
17
+ CURRENT_TURN_LINK_NAME,
18
+ FEEDBACK_FILENAME,
19
+ REPORT_FILENAME,
20
+ REPORT_JSON_INDENT,
21
+ REPORT_KIND_FILENAME,
22
+ ReportKind,
23
+ model_for,
24
+ )
25
+ from shikiru.errors import EXIT_NOT_INITIALISED, EXIT_USAGE_ERROR, ShikiruError
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Mapping
29
+
30
+ from pydantic import BaseModel
31
+
32
+ from shikiru.domain.reports import Report
33
+
34
+
35
+ class ReportError(ShikiruError):
36
+ """A failure while handling `shikiru report`, with the process exit code to return.
37
+
38
+ `exit_code` is set per instance rather than per subclass. Invalid input and a missing current
39
+ turn use different exit codes through this same error type.
40
+ """
41
+
42
+ def __init__(self, exit_code: int, message: str) -> None:
43
+ """Record the process `exit_code` and the human-readable `message` for stderr."""
44
+ super().__init__(message)
45
+ self.exit_code = exit_code
46
+ self.message = message
47
+
48
+
49
+ def resolve_current_turn_dir(environ: Mapping[str, str]) -> Path:
50
+ """Resolve `$SHIKIRU_RUN_DIR/$SHIKIRU_ROLE/current_turn` to its target directory."""
51
+ run_dir = environ.get(RUN_DIR_ENV_VAR)
52
+ role = environ.get(ROLE_ENV_VAR)
53
+ if not run_dir or not role:
54
+ message = f"{RUN_DIR_ENV_VAR} and {ROLE_ENV_VAR} must both be set"
55
+ raise ReportError(EXIT_NOT_INITIALISED, message)
56
+ link = role_dir(Path(run_dir), role) / CURRENT_TURN_LINK_NAME
57
+ if not link.is_symlink() or not link.exists():
58
+ message = f"no current turn: {link} is missing or does not point at a live turn dir"
59
+ raise ReportError(EXIT_NOT_INITIALISED, message)
60
+ return link.resolve()
61
+
62
+
63
+ def _report_kind(turn_dir: Path) -> ReportKind:
64
+ kind_path = turn_dir / REPORT_KIND_FILENAME
65
+ if not kind_path.is_file():
66
+ message = f"{kind_path} is missing"
67
+ raise ReportError(EXIT_USAGE_ERROR, message)
68
+ raw_kind = kind_path.read_text().strip()
69
+ try:
70
+ return ReportKind(raw_kind)
71
+ except ValueError:
72
+ message = f"{kind_path}: unknown report kind {raw_kind!r}"
73
+ raise ReportError(EXIT_USAGE_ERROR, message) from None
74
+
75
+
76
+ def _load_report_data(file_path: Path) -> object:
77
+ try:
78
+ raw_text = file_path.read_text()
79
+ except OSError as error:
80
+ message = f"cannot read {file_path}: {error}"
81
+ raise ReportError(EXIT_USAGE_ERROR, message) from error
82
+ try:
83
+ return json.loads(raw_text)
84
+ except json.JSONDecodeError as error:
85
+ message = f"{file_path}: invalid JSON: {error}"
86
+ raise ReportError(EXIT_USAGE_ERROR, message) from error
87
+
88
+
89
+ def _validation_error_messages(error: ValidationError) -> list[str]:
90
+ messages: list[str] = []
91
+ for violation in error.errors():
92
+ field_path = ".".join(str(part) for part in violation["loc"]) or "<root>"
93
+ messages.append(f"{field_path}: {violation['msg']}")
94
+ return messages
95
+
96
+
97
+ def validate_report(turn_dir: Path, file_path: Path) -> BaseModel:
98
+ """Validate `file_path`'s JSON against the model named in `turn_dir`'s `report.kind`."""
99
+ kind = _report_kind(turn_dir)
100
+ data = _load_report_data(file_path)
101
+ model = model_for(kind)
102
+ try:
103
+ report = model.model_validate(data)
104
+ except ValidationError as error:
105
+ message = "\n".join(_validation_error_messages(error))
106
+ raise ReportError(EXIT_USAGE_ERROR, message) from error
107
+ _check_feedback_contract(turn_dir, report)
108
+ return report
109
+
110
+
111
+ def _check_feedback_contract(turn_dir: Path, report: Report) -> None:
112
+ feedback_path = turn_dir / FEEDBACK_FILENAME
113
+ if not feedback_path.is_file():
114
+ return
115
+ try:
116
+ feedback = FeedbackInput.model_validate_json(feedback_path.read_text())
117
+ check_feedback_report(feedback, report)
118
+ except (ValidationError, OSError) as error:
119
+ message = f"cannot read {feedback_path}: {error}"
120
+ raise ReportError(EXIT_USAGE_ERROR, message) from error
121
+ except FeedbackContractError as error:
122
+ raise ReportError(EXIT_USAGE_ERROR, str(error)) from error
123
+
124
+
125
+ def write_report(turn_dir: Path, report: BaseModel) -> Path:
126
+ """Write `report` to `turn_dir/report.json`, replacing it atomically."""
127
+ report_path = turn_dir / REPORT_FILENAME
128
+ descriptor, temp_name = tempfile.mkstemp(dir=turn_dir, prefix=f".{REPORT_FILENAME}.")
129
+ try:
130
+ with os.fdopen(descriptor, "w") as temp_file:
131
+ temp_file.write(report.model_dump_json(indent=REPORT_JSON_INDENT))
132
+ Path(temp_name).replace(report_path)
133
+ except BaseException:
134
+ Path(temp_name).unlink(missing_ok=True)
135
+ raise
136
+ return report_path
137
+
138
+
139
+ def submit_report(file_path: Path, environ: Mapping[str, str]) -> Path:
140
+ """Resolve the current turn, validate `file_path` against its report kind, and store it."""
141
+ turn_dir = resolve_current_turn_dir(environ)
142
+ report = validate_report(turn_dir, file_path)
143
+ return write_report(turn_dir, report)
144
+
145
+
146
+ _FILE_OPTION = typer.Option(..., "--file", exists=True, dir_okay=False, help="Report JSON.")
147
+
148
+
149
+ def report_command(file: Path = _FILE_OPTION) -> None:
150
+ """Validate and store the current turn's report."""
151
+ try:
152
+ report_path = submit_report(file, os.environ)
153
+ except ReportError as error:
154
+ typer.echo(error.message, err=True)
155
+ raise typer.Exit(code=error.exit_code) from None
156
+ typer.echo(f"report accepted: {report_path}")
@@ -0,0 +1,63 @@
1
+ """Plain numbered session selection for the attach command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import signal
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.text import Text
9
+
10
+ from shikiru.cli.render import session_options_renderable
11
+ from shikiru.errors import UsageError
12
+ from shikiru.ui.console import human_console
13
+ from shikiru.ui.section import BOLD_STEEL
14
+ from shikiru.ui.tokens import SECONDARY_TEXT_COLOR
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Sequence
18
+
19
+ from shikiru.operator.sessions import SessionRow
20
+
21
+
22
+ SESSION_CANCELLED = "session selection was cancelled"
23
+ SESSION_PROMPT_PREFIX = "Select a session number (or "
24
+ SESSION_PROMPT_SUFFIX = " to cancel): "
25
+ SESSION_CANCEL_CHOICES = frozenset({"q", "quit"})
26
+
27
+
28
+ def prompt_for_session(rows: Sequence[SessionRow], host: str | None) -> SessionRow:
29
+ """Show full session identities and return the validated numbered selection."""
30
+ console = human_console()
31
+ console.print(session_options_renderable(tuple(rows), host=host, console=console))
32
+ while True:
33
+ prompt = Text.assemble(
34
+ (SESSION_PROMPT_PREFIX, SECONDARY_TEXT_COLOR),
35
+ ("q", BOLD_STEEL),
36
+ (SESSION_PROMPT_SUFFIX, SECONDARY_TEXT_COLOR),
37
+ )
38
+ console.print(prompt, end="")
39
+ previous_handler = signal.signal(signal.SIGINT, _interrupt_input)
40
+ try:
41
+ answer = input().strip()
42
+ except (EOFError, KeyboardInterrupt) as error:
43
+ raise UsageError(SESSION_CANCELLED) from error
44
+ finally:
45
+ signal.signal(signal.SIGINT, previous_handler)
46
+ if answer.lower() in SESSION_CANCEL_CHOICES:
47
+ raise UsageError(SESSION_CANCELLED)
48
+ if answer.isdecimal():
49
+ try:
50
+ selected = int(answer)
51
+ except ValueError:
52
+ pass
53
+ else:
54
+ if 1 <= selected <= len(rows):
55
+ return rows[selected - 1]
56
+ retry = Text(f"Enter a number from 1 to {len(rows)}, or ", style=SECONDARY_TEXT_COLOR)
57
+ retry.append("q", style=BOLD_STEEL)
58
+ retry.append(" to cancel.", style=SECONDARY_TEXT_COLOR)
59
+ console.print(retry)
60
+
61
+
62
+ def _interrupt_input(_signum: int, _frame: object) -> None:
63
+ raise KeyboardInterrupt
shikiru/cli/view.py ADDED
@@ -0,0 +1,131 @@
1
+ """`shikiru view`: a role pane's read-only live tail of its `view.jsonl`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from typing import TYPE_CHECKING, Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from shikiru.cli.context import project_root, state_from
14
+ from shikiru.clock import SystemClock, to_local
15
+ from shikiru.domain.tasks import Role
16
+ from shikiru.paths import run_dir
17
+ from shikiru.ui.quota_status import quota_status_text
18
+ from shikiru.ui.status_strip import StatusStrip
19
+ from shikiru.ui.stream import StreamLine
20
+ from shikiru.ui.tokens import AMBER
21
+ from shikiru.view_log import quota_status_path, read_quota_status, view_log_path
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+ from shikiru.clock import Clock
27
+
28
+ _LINE_END = b"\n"
29
+ _VIEW_POLL_SECONDS = 0.5
30
+ _VIEW_READ_BYTES = 65_536
31
+
32
+
33
+ class ViewTail:
34
+ """Follows one `view.jsonl`, printing each complete row through a `StreamLine`.
35
+
36
+ While `status_path` exists, its quota wait shows as a status strip above the output.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ path: Path,
42
+ stream: StreamLine,
43
+ console: Console,
44
+ clock: Clock,
45
+ *,
46
+ status_path: Path,
47
+ strip: StatusStrip,
48
+ ) -> None:
49
+ """Tail `path` from its start, rendering with `stream` onto `console`."""
50
+ self._path = path
51
+ self._stream = stream
52
+ self._console = console
53
+ self._clock = clock
54
+ self._status_path = status_path
55
+ self._strip = strip
56
+ self._offset = 0
57
+ self._pending_bytes = b""
58
+ self._size = (console.size.width, console.size.height)
59
+
60
+ async def follow(self) -> None:
61
+ """Print rows as they are appended, polling through the clock until cancelled."""
62
+ while True:
63
+ size = self._console.size
64
+ resized = (size.width, size.height) != self._size and self._strip.active
65
+ if resized:
66
+ self._reset_for_replay()
67
+ self.sync_status()
68
+ while self.drain():
69
+ await self._clock.sleep(0)
70
+ if not resized and self.sync_status():
71
+ self._reset_for_replay()
72
+ self.sync_status()
73
+ while self.drain():
74
+ await self._clock.sleep(0)
75
+ size = self._console.size
76
+ self._size = (size.width, size.height)
77
+ await self._clock.sleep(_VIEW_POLL_SECONDS)
78
+
79
+ def sync_status(self) -> bool:
80
+ """Show the published quota wait and report whether output needs replaying."""
81
+ status = read_quota_status(self._status_path)
82
+ if status is None:
83
+ return self._strip.sync(None)
84
+ action, at = status
85
+ now = to_local(self._clock.now())
86
+ return self._strip.sync(
87
+ quota_status_text(action, to_local(at) if at is not None else None, now)
88
+ )
89
+
90
+ def _reset_for_replay(self) -> None:
91
+ self._strip.reset_for_replay()
92
+ self._stream.reset()
93
+ self._offset = 0
94
+ self._pending_bytes = b""
95
+
96
+ def drain(self) -> bool:
97
+ """Print complete rows from one bounded chunk; return whether more may remain."""
98
+ if not self._path.is_file():
99
+ return False
100
+ with self._path.open("rb") as log:
101
+ log.seek(self._offset)
102
+ chunk = log.read(_VIEW_READ_BYTES)
103
+ if not chunk:
104
+ return False
105
+ self._offset += len(chunk)
106
+ combined = self._pending_bytes + chunk
107
+ complete = combined[: combined.rfind(_LINE_END) + 1]
108
+ self._pending_bytes = combined[len(complete) :]
109
+ for raw in complete.splitlines():
110
+ for line in self._stream.render(json.loads(raw)):
111
+ self._console.print(line)
112
+ return len(chunk) == _VIEW_READ_BYTES
113
+
114
+
115
+ def view_command(
116
+ context: typer.Context,
117
+ task_id: Annotated[str, typer.Argument()],
118
+ role: Annotated[Role, typer.Argument()],
119
+ ) -> None:
120
+ """Render `role`'s live view of `task_id`."""
121
+ root = project_root(state_from(context), require_config=False)
122
+ console = Console()
123
+ tail = ViewTail(
124
+ view_log_path(run_dir(root, task_id), role.value),
125
+ StreamLine(role),
126
+ console,
127
+ SystemClock(),
128
+ status_path=quota_status_path(run_dir(root, task_id), role.value),
129
+ strip=StatusStrip(console, sys.stdin, AMBER),
130
+ )
131
+ asyncio.run(tail.follow())
shikiru/clock.py ADDED
@@ -0,0 +1,41 @@
1
+ """Injectable time: every timer in Shikiru goes through a `Clock`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import UTC, datetime
7
+ from typing import Protocol
8
+
9
+
10
+ class Clock(Protocol):
11
+ """A source of the current time and of delay, injected wherever either is needed."""
12
+
13
+ def now(self) -> datetime:
14
+ """Return the current UTC-aware time."""
15
+ ...
16
+
17
+ async def sleep(self, seconds: float) -> None:
18
+ """Suspend the caller for `seconds`."""
19
+ ...
20
+
21
+
22
+ class SystemClock:
23
+ """A `Clock` backed by the real wall clock and event loop."""
24
+
25
+ def now(self) -> datetime:
26
+ """Return the current UTC-aware time."""
27
+ return datetime.now(UTC)
28
+
29
+ async def sleep(self, seconds: float) -> None:
30
+ """Suspend the caller for `seconds`."""
31
+ await asyncio.sleep(seconds)
32
+
33
+
34
+ def to_local(value: datetime) -> datetime:
35
+ """Return `value` as the viewer's local time, using the offset in force at that instant."""
36
+ return value.astimezone()
37
+
38
+
39
+ def from_local(value: datetime) -> datetime:
40
+ """Return the aware instant the naive wall-clock `value` names in the viewer's zone."""
41
+ return value.astimezone()
@@ -0,0 +1,8 @@
1
+ """Configuration: merge, validate and expose `Settings`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from shikiru.config.loader import load_settings
6
+ from shikiru.config.settings import Settings
7
+
8
+ __all__ = ["Settings", "load_settings"]
@@ -0,0 +1,131 @@
1
+ [project]
2
+ trackers = []
3
+
4
+ [sessions]
5
+ ssh_timeout_seconds = 30
6
+
7
+ [orchestration]
8
+ max_parallel = 2
9
+ max_iterations = 5
10
+ integration = "merge"
11
+ pr_feedback = "changes_requested"
12
+ push_interval_seconds = 14400
13
+ remote_recheck_seconds = 60
14
+ poll_seconds = 2
15
+ shutdown_seconds = 15
16
+ dispatch_budget_seconds = 10
17
+ engine_start_seconds = 5
18
+
19
+ [orchestration.resources]
20
+ max_cpu_pct = 85
21
+ min_free_ram_gb = 4
22
+ sample_seconds = 2
23
+ cpu_window_seconds = 30
24
+
25
+ [orchestration.timeouts]
26
+ stall = 1200
27
+ tool = 7200
28
+ max_quota_wait = 28800
29
+ integration_wait = 1800
30
+ pr_wait = 604800
31
+ helper = 300
32
+ cache_copy = 600
33
+ git_command = 120
34
+
35
+ [orchestration.faults]
36
+ backoff_initial_seconds = 2
37
+ backoff_max_seconds = 60
38
+ unexpected_fault_limit = 10
39
+ stop_notify_seconds = 30
40
+ repeat_notify_seconds = 300
41
+
42
+ [orchestration.retries]
43
+ unavailable_initial_seconds = 30
44
+ unavailable_multiplier = 4
45
+ unavailable_max_seconds = 300
46
+ unavailable_max_retries = 3
47
+
48
+ [orchestration.quota_refresh]
49
+ interval_seconds = 300
50
+ timeout_seconds = 30
51
+
52
+ [orchestration.network]
53
+ fetch_timeout_seconds = 30
54
+ poll_attempts = 8
55
+ pr_poll_seconds = 300
56
+ pr_close_attempts = 5
57
+
58
+ [escalation]
59
+ tried_lines = 8
60
+
61
+ [roles.implementer]
62
+ escalate_after = 2
63
+
64
+ [dashboard]
65
+ widgets = ["header", "escalations", "strips", "queue", "dialogue", "usage", "resources", "docker", "disk", "faults"]
66
+ animations = true
67
+ docker_refresh_seconds = 5
68
+ docker_timeout_seconds = 5
69
+ docker_scope = "project"
70
+ disk_refresh_seconds = 60
71
+ disk_size_refresh_seconds = 300
72
+ disk_warn_free_pct = 10
73
+ disk_alert_free_pct = 5
74
+ repo_status_cache_seconds = 10
75
+ task_work_cache_seconds = 2
76
+ timeline_page_size = 20
77
+ timeline_scan_rows = 256
78
+ timeline_refresh_seconds = 1
79
+ recent_task_limit = 30
80
+ recent_task_window = 3
81
+ last_words_chars = 4000
82
+ now_max_lines = 6
83
+ now_compact_lines = 2
84
+ results_commits = 3
85
+ action_confirmation_seconds = 5
86
+
87
+ [dashboard.motion]
88
+ refresh_hz = 4
89
+ stamp_flash_milliseconds = 400
90
+ escalation_pulse_hz = 1
91
+ task_event_limit = 50
92
+ merge_event_limit = 20
93
+
94
+ [dashboard.layout]
95
+ split = "v"
96
+ panes = [
97
+ { pane = "leader", size = 40 },
98
+ { pane = "implementer", size = 40 },
99
+ { pane = "shell", size = 20 },
100
+ ]
101
+
102
+ [dashboard.titles]
103
+ window = "{task_id} {title:.24}"
104
+ leader = "{role_label} · {agent}:{model}/{effort}{pending_rung} · iter {iteration} · ctx {context} · {cost} {flags_text}"
105
+ implementer = "{role_label} · {agent}:{model}/{effort}{pending_rung} · iter {iteration} · ctx {context} · {cost} {flags_text}"
106
+ system = "Shikiru · {project} · {active}/{max_parallel} active · {queued} queued"
107
+ shell = "Project folder · {cwd}"
108
+ context_warn_pct = 40
109
+ context_alert_pct = 60
110
+
111
+ [dashboard.gauges]
112
+ quota_warn_pct = 50
113
+ quota_alert_pct = 90
114
+
115
+ [notify]
116
+ command = ""
117
+ timeout_seconds = 30
118
+
119
+ [logging]
120
+ engine_log_max_bytes = 10485760
121
+ engine_log_backups = 3
122
+
123
+ [doctor]
124
+ probe_timeout = 180
125
+ probe_stream_tail_lines = 20
126
+
127
+ [retention]
128
+ interval_seconds = 3600
129
+ finished_task_days = 30
130
+ event_days = 90
131
+ command_days = 30