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,319 @@
1
+ """Operator command acknowledgements over the typed command queue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from datetime import timedelta
7
+ from enum import StrEnum, auto
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from pydantic import BaseModel, ConfigDict, PrivateAttr
11
+
12
+ from shikiru.domain.commands import (
13
+ COMMAND_POLL_SECONDS,
14
+ AcceptResolution,
15
+ AnswerResolution,
16
+ Cancel,
17
+ CancelResolution,
18
+ HandBack,
19
+ Pause,
20
+ Prioritize,
21
+ Resolution,
22
+ ResolutionKind,
23
+ Resolve,
24
+ Resume,
25
+ Retry,
26
+ RetryResolution,
27
+ Say,
28
+ SetRung,
29
+ Stop,
30
+ Submit,
31
+ TakeOver,
32
+ )
33
+ from shikiru.domain.tasks import title_from_request
34
+ from shikiru.errors import DatabaseError
35
+ from shikiru.store.commands import CommandQueue, CommandStatus
36
+ from shikiru.store.database import OperationalDatabaseError
37
+
38
+ if TYPE_CHECKING:
39
+ from shikiru.agents.contracts import Rung
40
+ from shikiru.clock import Clock
41
+ from shikiru.config.settings import ComplexityName
42
+ from shikiru.domain.commands import Command
43
+ from shikiru.domain.tasks import Mode, Role
44
+
45
+ COMMAND_ACK_WAIT_SECONDS = 10.0
46
+ QUEUED_MESSAGE = "applies when the engine starts"
47
+ ACK_TIMEOUT_MESSAGE = (
48
+ f"engine did not acknowledge the command within {COMMAND_ACK_WAIT_SECONDS:g} seconds"
49
+ )
50
+
51
+ EngineState = Callable[[], bool]
52
+
53
+
54
+ class AckStatus(StrEnum):
55
+ """The operator-visible command lifecycle, including an engine-down queue state."""
56
+
57
+ DONE = auto()
58
+ REJECTED = auto()
59
+ QUEUED = auto()
60
+
61
+
62
+ class Ack(BaseModel):
63
+ """The result returned after a command is queued or handled by the engine."""
64
+
65
+ model_config = ConfigDict(extra="forbid", frozen=True)
66
+
67
+ id: int
68
+ status: AckStatus
69
+ result: dict[str, Any] | None = None
70
+ message: str | None = None
71
+ _submitted_task_id: str | None = PrivateAttr(default=None)
72
+
73
+ @property
74
+ def submitted_task_id(self) -> str | None:
75
+ """Return the task identity allocated for a submitted command, when applicable."""
76
+ return self._submitted_task_id
77
+
78
+ @classmethod
79
+ def from_command(
80
+ cls,
81
+ command: Command,
82
+ *,
83
+ command_id: int,
84
+ status: AckStatus,
85
+ result: dict[str, Any] | None,
86
+ message: str | None,
87
+ ) -> Ack:
88
+ """Create an acknowledgement while retaining submitted task identity for human output."""
89
+ ack = cls(id=command_id, status=status, result=result, message=message)
90
+ if isinstance(command, Submit):
91
+ ack._submitted_task_id = command.task_id
92
+ return ack
93
+
94
+
95
+ class OperatorCommands:
96
+ """Append typed commands and wait briefly for a running engine's acknowledgement.
97
+
98
+ A database that stays locked past its busy timeout raises `DatabaseError`; nothing is queued.
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ queue: CommandQueue,
104
+ clock: Clock,
105
+ *,
106
+ engine_up: EngineState,
107
+ ) -> None:
108
+ """Use `queue` and `clock` for command and acknowledgement boundaries."""
109
+ self._queue = queue
110
+ self._clock = clock
111
+ self._engine_up = engine_up
112
+
113
+ async def send(self, command: Command) -> Ack:
114
+ """Queue `command`, returning done/rejected or queued when the engine is down."""
115
+ try:
116
+ queued = self._queue.append(command)
117
+ except OperationalDatabaseError as error:
118
+ message = f"the state database is busy and the command was not queued: {error}"
119
+ raise DatabaseError(message) from error
120
+ if not self._engine_up():
121
+ return _queued_ack(queued.id, queued.command, QUEUED_MESSAGE)
122
+ deadline = self._clock.now() + timedelta(seconds=COMMAND_ACK_WAIT_SECONDS)
123
+ while True:
124
+ outcome = self._queue.status(queued.id)
125
+ if outcome is not None and outcome.status is not CommandStatus.PENDING:
126
+ status = (
127
+ AckStatus.DONE if outcome.status is CommandStatus.DONE else AckStatus.REJECTED
128
+ )
129
+ message = _result_message(outcome.result)
130
+ return Ack.from_command(
131
+ queued.command,
132
+ command_id=outcome.id,
133
+ status=status,
134
+ result=outcome.result,
135
+ message=message,
136
+ )
137
+ if self._clock.now() >= deadline:
138
+ return _queued_ack(queued.id, queued.command, ACK_TIMEOUT_MESSAGE)
139
+ await self._clock.sleep(COMMAND_POLL_SECONDS)
140
+
141
+ async def submit_task(
142
+ self,
143
+ request: str,
144
+ *,
145
+ title: str | None = None,
146
+ mode: Mode | None = None,
147
+ complexity: ComplexityName | None = None,
148
+ priority: int = 0,
149
+ depends_on: Sequence[str] = (),
150
+ leader: Rung | None = None,
151
+ implementer: Rung | None = None,
152
+ ) -> Ack:
153
+ """Queue a `Submit` command, allocating its task id atomically in the store."""
154
+ return await self.send(
155
+ self.prepare_submit_task(
156
+ request,
157
+ title=title,
158
+ mode=mode,
159
+ complexity=complexity,
160
+ priority=priority,
161
+ depends_on=depends_on,
162
+ leader=leader,
163
+ implementer=implementer,
164
+ )
165
+ )
166
+
167
+ def prepare_submit_task(
168
+ self,
169
+ request: str,
170
+ *,
171
+ title: str | None = None,
172
+ mode: Mode | None = None,
173
+ complexity: ComplexityName | None = None,
174
+ priority: int = 0,
175
+ depends_on: Sequence[str] = (),
176
+ leader: Rung | None = None,
177
+ implementer: Rung | None = None,
178
+ ) -> Submit:
179
+ """Prepare the typed submission used by both palette recovery and dispatch."""
180
+ task_title = title if title is not None else title_from_request(request)
181
+ return Submit(
182
+ title=task_title,
183
+ request=request,
184
+ mode=mode,
185
+ complexity=complexity,
186
+ priority=priority,
187
+ depends_on=list(depends_on),
188
+ leader=leader,
189
+ implementer=implementer,
190
+ )
191
+
192
+ async def prioritize_task(self, task_id: str, priority: int) -> Ack:
193
+ """Queue a `Prioritize` command."""
194
+ return await self.send(self.prepare_prioritize_task(task_id, priority))
195
+
196
+ def prepare_prioritize_task(self, task_id: str, priority: int) -> Prioritize:
197
+ """Prepare the typed priority command used by the palette and dispatcher."""
198
+ return Prioritize(task_id=task_id, priority=priority)
199
+
200
+ async def pause_task(self, task_id: str, *, now: bool = False) -> Ack:
201
+ """Queue a `Pause` command."""
202
+ return await self.send(self.prepare_pause_task(task_id, now=now))
203
+
204
+ def prepare_pause_task(self, task_id: str, *, now: bool = False) -> Pause:
205
+ """Prepare the typed pause command used by the palette and dispatcher."""
206
+ return Pause(task_id=task_id, now=now)
207
+
208
+ async def resume_task(self, task_id: str) -> Ack:
209
+ """Queue a `Resume` command."""
210
+ return await self.send(self.prepare_resume_task(task_id))
211
+
212
+ def prepare_resume_task(self, task_id: str) -> Resume:
213
+ """Prepare the typed resume command used by the palette and dispatcher."""
214
+ return Resume(task_id=task_id)
215
+
216
+ async def cancel_task(self, task_id: str) -> Ack:
217
+ """Queue a `Cancel` command."""
218
+ return await self.send(self.prepare_cancel_task(task_id))
219
+
220
+ def prepare_cancel_task(self, task_id: str) -> Cancel:
221
+ """Prepare the typed cancel command used by the palette and dispatcher."""
222
+ return Cancel(task_id=task_id)
223
+
224
+ async def retry_task(self, task_id: str) -> Ack:
225
+ """Queue a `Retry` command."""
226
+ return await self.send(self.prepare_retry_task(task_id))
227
+
228
+ def prepare_retry_task(self, task_id: str) -> Retry:
229
+ """Prepare the typed retry command used by the palette and dispatcher."""
230
+ return Retry(task_id=task_id)
231
+
232
+ async def set_rung(self, task_id: str, role: Role, rung: Rung) -> Ack:
233
+ """Queue a `SetRung` command."""
234
+ return await self.send(self.prepare_set_rung(task_id, role, rung))
235
+
236
+ def prepare_set_rung(self, task_id: str, role: Role, rung: Rung) -> SetRung:
237
+ """Prepare the typed rung command used by the palette and dispatcher."""
238
+ return SetRung(task_id=task_id, role=role, rung=rung)
239
+
240
+ async def send_message(self, task_id: str, role: Role, text: str) -> Ack:
241
+ """Queue a `Say` command for the role's next turn."""
242
+ return await self.send(self.prepare_send_message(task_id, role, text))
243
+
244
+ def prepare_send_message(self, task_id: str, role: Role, text: str) -> Say:
245
+ """Prepare the typed guidance command used by the palette and dispatcher."""
246
+ return Say(task_id=task_id, role=role, text=text)
247
+
248
+ async def take_over(self, task_id: str, role: Role) -> Ack:
249
+ """Queue a `TakeOver` command for the role's pane."""
250
+ return await self.send(self.prepare_take_over(task_id, role))
251
+
252
+ def prepare_take_over(self, task_id: str, role: Role) -> TakeOver:
253
+ """Prepare the typed takeover command used by the palette and dispatcher."""
254
+ return TakeOver(task_id=task_id, role=role)
255
+
256
+ async def hand_back(self, task_id: str, role: Role) -> Ack:
257
+ """Queue a `HandBack` command returning the role's session to the engine."""
258
+ return await self.send(self.prepare_hand_back(task_id, role))
259
+
260
+ def prepare_hand_back(self, task_id: str, role: Role) -> HandBack:
261
+ """Prepare the typed hand-back command used by the palette and dispatcher."""
262
+ return HandBack(task_id=task_id, role=role)
263
+
264
+ async def resolve_escalation(self, escalation_id: str, resolution: Resolution) -> Ack:
265
+ """Queue a `Resolve` command for one open escalation."""
266
+ return await self.send(self.prepare_resolve_escalation(escalation_id, resolution))
267
+
268
+ def prepare_resolve_escalation(
269
+ self,
270
+ escalation_id: str,
271
+ resolution: Resolution,
272
+ ) -> Resolve:
273
+ """Prepare the typed resolution command used by the palette and dispatcher."""
274
+ return Resolve(escalation_id=escalation_id, resolution=resolution)
275
+
276
+ def prepare_palette_resolution(
277
+ self,
278
+ escalation_id: str,
279
+ kind: ResolutionKind,
280
+ *,
281
+ answer: str,
282
+ retry_rung: Rung | None,
283
+ ) -> Resolve:
284
+ """Prepare one resolution command from the palette's selected form fields."""
285
+ if kind is ResolutionKind.ANSWER:
286
+ resolution: Resolution = AnswerResolution(text=answer)
287
+ elif kind is ResolutionKind.RETRY:
288
+ resolution = RetryResolution(rung=retry_rung)
289
+ elif kind is ResolutionKind.ACCEPT:
290
+ resolution = AcceptResolution()
291
+ else:
292
+ resolution = CancelResolution()
293
+ return self.prepare_resolve_escalation(escalation_id, resolution)
294
+
295
+ async def stop_engine(self) -> Ack:
296
+ """Queue the orderly engine `Stop` command."""
297
+ return await self.send(self.prepare_stop_engine())
298
+
299
+ def prepare_stop_engine(self) -> Stop:
300
+ """Prepare the typed stop command used by the palette and dispatcher."""
301
+ return Stop()
302
+
303
+
304
+ def _queued_ack(command_id: int, command: Command, message: str) -> Ack:
305
+ result = {"task_id": command.task_id} if isinstance(command, Submit) else None
306
+ return Ack.from_command(
307
+ command,
308
+ command_id=command_id,
309
+ status=AckStatus.QUEUED,
310
+ result=result,
311
+ message=message,
312
+ )
313
+
314
+
315
+ def _result_message(result: dict[str, Any] | None) -> str | None:
316
+ if result is None:
317
+ return None
318
+ error = result.get("error")
319
+ return str(error) if error is not None else None
@@ -0,0 +1,374 @@
1
+ """Project detection and config rendering shared by non-interactive init and the wizard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from shikiru.agents.contracts import ModelCatalog, Rung, short_rung_text
13
+ from shikiru.config.loader import validate_project_config_text
14
+ from shikiru.errors import ConfigError, ProjectNotRepositoryRootError, RepositoryHasNoCommitsError
15
+ from shikiru.operator.toml_text import toml_key, toml_value
16
+ from shikiru.paths import EXCLUDE_LINE, SHIKIRU_DIR_NAME, project_config_path
17
+ from shikiru.private import create_private_text, make_private_dir
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Mapping, Sequence
21
+ from typing import Any
22
+
23
+ from shikiru.agents.settings import AgentSettings
24
+ from shikiru.config.settings import ComplexityName
25
+ from shikiru.operator.doctor import DoctorReport
26
+ from shikiru.operator.plugin_checks import (
27
+ AgentCapabilityResult,
28
+ OptionsRejection,
29
+ PluginChecks,
30
+ )
31
+ from shikiru.workspace.caches import CacheCloner
32
+ from shikiru.workspace.git import Git, LineChangeStats
33
+
34
+ MAX_SCAN_DEPTH = 4
35
+ TRACKER_DIRECTORY = "tasks"
36
+ TRACKER_NAMES = ("plan", "todo")
37
+ MARKDOWN_SUFFIX = ".md"
38
+ CACHE_NAMES = (
39
+ ".venv",
40
+ "node_modules",
41
+ "target",
42
+ ".gradle",
43
+ ".pytest_cache",
44
+ )
45
+ DEFAULT_MODEL = "default"
46
+ LEADER_ROLE = "leader"
47
+ IMPLEMENTER_ROLE = "implementer"
48
+ HELPER_ROLE = "helper"
49
+ ROLE_NAMES = (LEADER_ROLE, IMPLEMENTER_ROLE, HELPER_ROLE)
50
+ COMPLEXITY_NAMES: tuple[ComplexityName, ...] = ("low", "normal", "high")
51
+ EXCLUDED_DIRECTORIES = frozenset({SHIKIRU_DIR_NAME, ".git"})
52
+
53
+
54
+ def not_repository_root(root: Path) -> str:
55
+ """Explain why `root` cannot be a Shikiru project: it is not a git repository root."""
56
+ return (
57
+ f"{root} is not a git repository root; Shikiru supports one repository per project, "
58
+ "so run it from the repository's top-level directory"
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class DetectedProject:
64
+ """All filesystem and plugin facts used to write a default project config."""
65
+
66
+ name: str
67
+ base: str
68
+ worktree_clean: bool
69
+ caches: tuple[str, ...]
70
+ trackers: tuple[str, ...]
71
+ agents: tuple[str, ...]
72
+ agent_settings: tuple[AgentSettings, ...] = ()
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class InitWizardResult:
77
+ """The config path and final Doctor result returned when the wizard is complete."""
78
+
79
+ config_path: Path
80
+ doctor_report: DoctorReport | None
81
+ doctor_error: str | None = None
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class OrchestrationChoices:
86
+ """The orchestration values the wizard writes to the project config."""
87
+
88
+ max_parallel: int
89
+ max_iterations: int
90
+ integration: str
91
+ pr_feedback: str
92
+ push_interval_seconds: int
93
+ max_quota_wait: int
94
+ stall: int
95
+ tool: int
96
+ max_cpu_pct: int
97
+ min_free_ram_gb: float
98
+
99
+
100
+ class ProjectDetectors:
101
+ """Detect the repository base, trackers, installed agents and caches for `init`."""
102
+
103
+ def __init__(
104
+ self,
105
+ root: Path,
106
+ *,
107
+ git: Git,
108
+ plugin_checks: PluginChecks,
109
+ cache_cloner: CacheCloner,
110
+ ) -> None:
111
+ """Inspect `root` through injected git and agent-plugin check boundaries."""
112
+ self._root = root.resolve()
113
+ self._git = git
114
+ self._plugin_checks = plugin_checks
115
+ self._cache_cloner = cache_cloner
116
+
117
+ async def detect(self) -> DetectedProject:
118
+ """Return deterministic project facts, or an actionable config error."""
119
+ await self._validate_root()
120
+ return await self._detect_project()
121
+
122
+ async def _detect_project(self) -> DetectedProject:
123
+ agents = await self._agents()
124
+ if not agents:
125
+ message = "no shikiru.agents plugin is installed"
126
+ raise ConfigError(message)
127
+ base = await self._git.current_branch(self._root)
128
+ worktree_clean = not await self._git.status_porcelain(self._root)
129
+ return DetectedProject(
130
+ name=self._root.name,
131
+ base=base,
132
+ worktree_clean=worktree_clean,
133
+ caches=tuple(await self._caches()),
134
+ trackers=tuple(self._trackers()),
135
+ agents=agents,
136
+ agent_settings=tuple(self._plugin_checks.settings(name) for name in agents),
137
+ )
138
+
139
+ async def initialize(self) -> Path:
140
+ """Detect facts, write config and exclude `.shikiru/` in the repository."""
141
+ await self._validate_root()
142
+ config_path = project_config_path(self._root)
143
+ if await asyncio.to_thread(config_path.is_file):
144
+ return config_path
145
+ project = await self._detect_project()
146
+ await self._write_and_exclude(config_path, render_config(project))
147
+ return config_path
148
+
149
+ async def initialize_config(
150
+ self,
151
+ *,
152
+ trackers: Sequence[str],
153
+ caches: Sequence[str],
154
+ ladders: Mapping[str, Sequence[Rung]],
155
+ complexity: Mapping[ComplexityName, Sequence[Rung]] | None = None,
156
+ orchestration: OrchestrationChoices,
157
+ agent_options: Mapping[str, Mapping[str, object]] | None = None,
158
+ ) -> Path:
159
+ """Write the wizard's validated configuration and exclude `.shikiru/`."""
160
+ await self._validate_root()
161
+ project = await self._detect_project()
162
+ config_path = project_config_path(self._root)
163
+ contents = render_config(
164
+ project,
165
+ trackers=trackers,
166
+ caches=caches,
167
+ ladders=ladders,
168
+ complexity=complexity,
169
+ orchestration=orchestration,
170
+ agent_options=agent_options,
171
+ )
172
+ validate_project_config_text(contents)
173
+ await self._write_and_exclude(config_path, contents)
174
+ return config_path
175
+
176
+ async def check_cow_support(self) -> bool:
177
+ """Probe copy-on-write support using disposable files on the project filesystem."""
178
+ return await self._cache_cloner.check_support(self._root)
179
+
180
+ async def working_tree_line_changes(self) -> LineChangeStats:
181
+ """Return tracked and untracked working-tree change totals for the project repository."""
182
+ return await self._git.working_tree_line_changes(self._root)
183
+
184
+ async def model_catalog(self, name: str, options: Mapping[str, Any]) -> ModelCatalog | None:
185
+ """Return an agent's optional authoritative choices through the injected plugin boundary."""
186
+ return await self._plugin_checks.model_catalog(name, options)
187
+
188
+ def agent_options_rejection(
189
+ self, name: str, options: Mapping[str, Any]
190
+ ) -> OptionsRejection | None:
191
+ """Return the plugin's rejection of `options` for `name`, or `None` when accepted."""
192
+ return self._plugin_checks.options_rejection(name, options)
193
+
194
+ def agent_capabilities(self, name: str, options: Mapping[str, Any]) -> AgentCapabilityResult:
195
+ """Return generic optional capabilities through the injected plugin boundary."""
196
+ return self._plugin_checks.capabilities(name, options)
197
+
198
+ async def _validate_root(self) -> None:
199
+ repository_root = await self._git.repository_root(self._root)
200
+ if repository_root != self._root:
201
+ raise ProjectNotRepositoryRootError(
202
+ not_repository_root(self._root),
203
+ project_root=self._root,
204
+ repository_root=repository_root,
205
+ )
206
+ if not await self._git.has_commit(self._root):
207
+ message = (
208
+ f"{self._root} is a git repository without commits; create an initial commit, "
209
+ "then run shikiru init"
210
+ )
211
+ raise RepositoryHasNoCommitsError(message, project_root=self._root)
212
+
213
+ async def _write_and_exclude(self, config_path: Path, contents: str) -> None:
214
+ try:
215
+ await asyncio.to_thread(_write_config, config_path, contents)
216
+ except FileExistsError as error:
217
+ message = f"{config_path} already exists; refusing to overwrite it"
218
+ raise ConfigError(message) from error
219
+ await self._git.append_to_exclude(self._root, EXCLUDE_LINE)
220
+
221
+ def _trackers(self) -> list[str]:
222
+ candidates: set[str] = set()
223
+ for path in _files_at_depth(self._root, MAX_SCAN_DEPTH):
224
+ relative = path.relative_to(self._root).as_posix()
225
+ name = path.stem.lower()
226
+ if (
227
+ path.parent.name == TRACKER_DIRECTORY
228
+ or name.startswith(TRACKER_NAMES)
229
+ or name.endswith(TRACKER_NAMES)
230
+ ) and path.suffix.lower() == MARKDOWN_SUFFIX:
231
+ candidates.add(relative)
232
+ return sorted(candidates)
233
+
234
+ async def _caches(self) -> list[str]:
235
+ ignored = await self._git.ignored_paths(self._root)
236
+ return sorted(
237
+ path.relative_to(self._root).as_posix() for path in ignored if path.name in CACHE_NAMES
238
+ )
239
+
240
+ async def _agents(self) -> tuple[str, ...]:
241
+ results = [
242
+ await self._plugin_checks.check(name, {}) for name in self._plugin_checks.names()
243
+ ]
244
+ names = tuple(result.name for result in results if result.ok)
245
+ if names:
246
+ return names
247
+ if not results:
248
+ message = "no shikiru.agents plugin is installed"
249
+ else:
250
+ details = "; ".join(f"{result.name}: {result.detail}" for result in results)
251
+ message = f"no installed agent plugin passed doctor(): {details}"
252
+ raise ConfigError(message)
253
+
254
+
255
+ def render_config(
256
+ project: DetectedProject,
257
+ *,
258
+ trackers: Sequence[str] | None = None,
259
+ caches: Sequence[str] | None = None,
260
+ ladders: Mapping[str, Sequence[Rung]] | None = None,
261
+ complexity: Mapping[ComplexityName, Sequence[Rung]] | None = None,
262
+ orchestration: OrchestrationChoices | None = None,
263
+ agent_options: Mapping[str, Mapping[str, object]] | None = None,
264
+ ) -> str:
265
+ """Render a complete default config from detected project facts."""
266
+ agent = project.agents[0]
267
+ lines = [
268
+ "[project]",
269
+ f"name = {json.dumps(project.name)}",
270
+ f"trackers = {json.dumps(list(project.trackers if trackers is None else trackers))}",
271
+ "",
272
+ ]
273
+ lines.extend(
274
+ (
275
+ "[repo]",
276
+ f"base = {json.dumps(project.base)}",
277
+ f"caches = {json.dumps(list(project.caches if caches is None else caches))}",
278
+ "",
279
+ )
280
+ )
281
+ for name in project.agents:
282
+ lines.append(f"[agents.{toml_key(name)}]")
283
+ options = (agent_options or {}).get(name, {})
284
+ if options:
285
+ lines.append("")
286
+ lines.append(f"[agents.{toml_key(name)}.options]")
287
+ lines.extend(f"{toml_key(key)} = {toml_value(value)}" for key, value in options.items())
288
+ lines.append("")
289
+ selected_ladders = (
290
+ ladders
291
+ if ladders is not None
292
+ else {role: (Rung(agent=agent, model=DEFAULT_MODEL),) for role in ROLE_NAMES}
293
+ )
294
+ for role in ROLE_NAMES:
295
+ lines.append(f"[roles.{role}]")
296
+ lines.append(f"ladder = {_render_ladder(selected_ladders[role])}")
297
+ lines.append("")
298
+ for name in COMPLEXITY_NAMES:
299
+ rungs = complexity.get(name) if complexity is not None else None
300
+ if rungs is not None:
301
+ lines.append(f"[roles.{IMPLEMENTER_ROLE}.complexity.{name}]")
302
+ lines.append(f"ladder = {_render_ladder(rungs)}")
303
+ lines.append("")
304
+ if orchestration is not None:
305
+ lines.extend(
306
+ (
307
+ "[orchestration]",
308
+ f"max_parallel = {orchestration.max_parallel}",
309
+ f"max_iterations = {orchestration.max_iterations}",
310
+ f"integration = {json.dumps(orchestration.integration)}",
311
+ f"pr_feedback = {json.dumps(orchestration.pr_feedback)}",
312
+ f"push_interval_seconds = {orchestration.push_interval_seconds}",
313
+ "",
314
+ "[orchestration.timeouts]",
315
+ f"stall = {orchestration.stall}",
316
+ f"tool = {orchestration.tool}",
317
+ f"max_quota_wait = {orchestration.max_quota_wait}",
318
+ "",
319
+ "[orchestration.resources]",
320
+ f"max_cpu_pct = {orchestration.max_cpu_pct}",
321
+ f"min_free_ram_gb = {orchestration.min_free_ram_gb}",
322
+ "",
323
+ )
324
+ )
325
+ return "\n".join(lines)
326
+
327
+
328
+ def _render_ladder(rungs: Sequence[Rung]) -> str:
329
+ rung_texts = [_render_rung(rung) for rung in rungs]
330
+ return f"[{', '.join(rung_texts)}]"
331
+
332
+
333
+ def _render_rung(rung: Rung) -> str:
334
+ short = short_rung_text(rung)
335
+ if short is not None:
336
+ return json.dumps(short)
337
+ return (
338
+ "{ "
339
+ + ", ".join(
340
+ value
341
+ for value in (
342
+ f"agent = {json.dumps(rung.agent)}",
343
+ f"model = {json.dumps(rung.model)}",
344
+ None if rung.effort is None else f"effort = {json.dumps(rung.effort)}",
345
+ )
346
+ if value is not None
347
+ )
348
+ + " }"
349
+ )
350
+
351
+
352
+ def _write_config(config_path: Path, contents: str) -> None:
353
+ make_private_dir(config_path.parent)
354
+ create_private_text(config_path, contents)
355
+
356
+
357
+ def _files_at_depth(root: Path, max_depth: int) -> list[Path]:
358
+ files: list[Path] = []
359
+ pending: list[tuple[Path, int]] = [(root, 0)]
360
+ while pending:
361
+ directory, depth = pending.pop()
362
+ with os.scandir(directory) as entries:
363
+ for entry in sorted(entries, key=lambda item: item.name):
364
+ if entry.name in EXCLUDED_DIRECTORIES:
365
+ continue
366
+ entry_depth = depth + 1
367
+ if entry_depth > max_depth:
368
+ continue
369
+ path = Path(entry.path)
370
+ if entry.is_dir(follow_symlinks=False):
371
+ pending.append((path, entry_depth))
372
+ elif entry.is_file(follow_symlinks=False):
373
+ files.append(path)
374
+ return files