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,498 @@
1
+ """Report models an agent's turn produces, validated by `shikiru report`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from enum import StrEnum, auto
7
+ from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Self
8
+
9
+ from pydantic import Field, StringConstraints, model_validator
10
+
11
+ from shikiru.config.settings import ComplexityName
12
+ from shikiru.domain.tasks import TASK_TITLE_MAX_LENGTH, DomainModel, Mode
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ DECISION_ACCEPT: Final = "accept"
18
+ DECISION_SUPERSEDE: Final = "supersede"
19
+ DECISION_CHANGES_REQUESTED: Final = "changes_requested"
20
+ DECISION_ANSWER: Final = "answer"
21
+ DECISION_ESCALATE: Final = "escalate"
22
+ SEVERITY_BLOCKER: Final = "blocker"
23
+ STATUS_COMPLETED: Final = "completed"
24
+ REPORT_DECISION_FIELD: Final = "decision"
25
+ COMMIT_SUBJECT_FIELD: Final = "commit_subject"
26
+
27
+ REPORT_JSON_INDENT = 2
28
+ CURRENT_TURN_LINK_NAME = "current_turn"
29
+ PROMPT_FILENAME = "prompt.md"
30
+ FACTS_FILENAME = "facts.md"
31
+ REPORT_KIND_FILENAME = "report.kind"
32
+ FEEDBACK_FILENAME = "feedback.json"
33
+ REPORT_SCHEMA_FILENAME = "report.schema.json"
34
+ REPORT_FILENAME = "report.json"
35
+ HELPER_REPORT_FILENAME = "helper.json"
36
+
37
+ SIBLING_REFERENCE_PREFIX = "#"
38
+ DEPENDS_ON_PATTERN = rf"^(T-\d+|{SIBLING_REFERENCE_PREFIX}\d+)$"
39
+
40
+ COMMIT_SUBJECT_MAX_LENGTH: Final = 72
41
+ _COMMIT_SUBJECT_LINE_BREAK = re.compile(r"[\r\n]")
42
+
43
+ _TaskReference = Annotated[str, StringConstraints(pattern=DEPENDS_ON_PATTERN)]
44
+ _Text = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
45
+
46
+
47
+ def resolve_dependency(reference: str, sibling_ids: Sequence[str]) -> str:
48
+ """Replace a report-local sibling reference with the assigned task id."""
49
+ if reference.startswith(SIBLING_REFERENCE_PREFIX):
50
+ return sibling_ids[int(reference.removeprefix(SIBLING_REFERENCE_PREFIX)) - 1]
51
+ return reference
52
+
53
+
54
+ def _require_iff(*, condition: bool, present: bool, field: str, rule: str) -> None:
55
+ if condition and not present:
56
+ message = f"{field}: required when {rule}"
57
+ raise ValueError(message)
58
+ if not condition and present:
59
+ message = f"{field}: must be empty unless {rule}"
60
+ raise ValueError(message)
61
+
62
+
63
+ def _require_commit_subject(subject: str) -> None:
64
+ if (
65
+ not subject
66
+ or subject != subject.strip()
67
+ or _COMMIT_SUBJECT_LINE_BREAK.search(subject)
68
+ or len(subject) > COMMIT_SUBJECT_MAX_LENGTH
69
+ ):
70
+ message = (
71
+ f"commit_subject: {subject!r} must be a non-empty, single-line subject of at most "
72
+ f"{COMMIT_SUBJECT_MAX_LENGTH} characters"
73
+ )
74
+ raise ValueError(message)
75
+
76
+
77
+ def _require_unique(identities: list[str], field: str) -> None:
78
+ duplicates = sorted({identity for identity in identities if identities.count(identity) > 1})
79
+ if duplicates:
80
+ message = f"{field}: duplicate ids {duplicates}"
81
+ raise ValueError(message)
82
+
83
+
84
+ def _require_sibling_references_in_range(new_tasks: list[NewTask], field: str) -> None:
85
+ for new_task in new_tasks:
86
+ for reference in new_task.depends_on:
87
+ if reference.startswith(SIBLING_REFERENCE_PREFIX) and not (
88
+ 1 <= int(reference[1:]) <= len(new_tasks)
89
+ ):
90
+ message = (
91
+ f"{field}: sibling reference {reference!r} must be #1..#{len(new_tasks)} "
92
+ "(1-based)"
93
+ )
94
+ raise ValueError(message)
95
+
96
+
97
+ class NewTask(DomainModel):
98
+ """A task to enqueue from a plan or verdict report."""
99
+
100
+ title: str
101
+ request: str
102
+ priority: int = 0
103
+ depends_on: list[_TaskReference] = Field(
104
+ default_factory=list,
105
+ description=(
106
+ "Each item is an existing 'T-n' or a sibling '#k': the k-th task of this list, "
107
+ "counting from 1."
108
+ ),
109
+ )
110
+
111
+
112
+ class Commit(DomainModel):
113
+ """One commit an agent made in the project's repository during its turn."""
114
+
115
+ sha: str
116
+ subject: str
117
+
118
+
119
+ class TestRun(DomainModel):
120
+ """One test (or test suite) the agent ran."""
121
+
122
+ name: str
123
+ result: Literal["passed", "failed", "skipped"]
124
+ details: str = ""
125
+
126
+
127
+ class Issue(DomainModel):
128
+ """One issue a verdict raises against an implementation."""
129
+
130
+ severity: Literal["blocker", "major", "minor"]
131
+ description: str
132
+ location: str = ""
133
+
134
+
135
+ class Option(DomainModel):
136
+ """One option offered to the Operator in an escalation."""
137
+
138
+ label: str
139
+ consequences: str
140
+ recommended: bool = False
141
+ needs_input: bool = Field(
142
+ default=False,
143
+ description="True when choosing this option means the Operator must supply something.",
144
+ )
145
+
146
+
147
+ class EscalationRequest(DomainModel):
148
+ """The Leader's escalation packet content: a question and its options."""
149
+
150
+ question: str
151
+ options: list[Option] = Field(description="Exactly one option must have recommended=true.")
152
+
153
+ @model_validator(mode="after")
154
+ def _exactly_one_recommended_option(self) -> Self:
155
+ recommended = [option for option in self.options if option.recommended]
156
+ if len(recommended) != 1:
157
+ message = f"options: exactly one option must be recommended, found {len(recommended)}"
158
+ raise ValueError(message)
159
+ return self
160
+
161
+
162
+ class TriageReport(DomainModel):
163
+ """The Leader's `triage` report."""
164
+
165
+ title: str = Field(max_length=TASK_TITLE_MAX_LENGTH)
166
+ mode: Mode
167
+ complexity: ComplexityName
168
+ acceptance_criteria: list[str]
169
+ references: list[str]
170
+
171
+
172
+ class ImplementationReport(DomainModel):
173
+ """The Implementer's `implement`/`rework`/`conflicts` report."""
174
+
175
+ status: Literal["completed", "needs_decision", "blocked"]
176
+ summary: str
177
+ commits: list[Commit]
178
+ tests: list[TestRun]
179
+ trackers_updated: bool
180
+ risks: list[str] = Field(default_factory=list)
181
+ question: str = Field(
182
+ default="", description="Required unless status is 'completed'; otherwise empty."
183
+ )
184
+
185
+ @model_validator(mode="after")
186
+ def _question_required_unless_completed(self) -> Self:
187
+ if self.status != STATUS_COMPLETED and not self.question:
188
+ message = "question: required unless status is 'completed'"
189
+ raise ValueError(message)
190
+ return self
191
+
192
+
193
+ class PlanReport(DomainModel):
194
+ """The Leader's `plan` report."""
195
+
196
+ summary: str
197
+ commit_subject: str = Field(
198
+ description=(
199
+ "A concise subject for the whole change following the target project's documented "
200
+ "commit convention, or Conventional Commits when none is documented; no task reference."
201
+ )
202
+ )
203
+ commits: list[Commit]
204
+ new_tasks: list[NewTask] = Field(default_factory=list)
205
+ scope_changes: list[str] = Field(default_factory=list)
206
+
207
+ @model_validator(mode="after")
208
+ def _commit_subject_is_valid(self) -> Self:
209
+ _require_commit_subject(self.commit_subject)
210
+ return self
211
+
212
+ @model_validator(mode="after")
213
+ def _sibling_references_in_range(self) -> Self:
214
+ _require_sibling_references_in_range(self.new_tasks, "new_tasks")
215
+ return self
216
+
217
+
218
+ class ConsultAnswer(DomainModel):
219
+ """The Leader's answer to a `consult` turn."""
220
+
221
+ decision: Literal["answer", "escalate"]
222
+ answer: str = Field(
223
+ default="", description="Required and non-empty iff decision is 'answer'; otherwise empty."
224
+ )
225
+ escalation: EscalationRequest | None = Field(
226
+ default=None, description="Required iff decision is 'escalate'; otherwise unset."
227
+ )
228
+
229
+ @model_validator(mode="after")
230
+ def _answer_required_iff_decision_is_answer(self) -> Self:
231
+ _require_iff(
232
+ condition=self.decision == DECISION_ANSWER,
233
+ present=bool(self.answer),
234
+ field="answer",
235
+ rule="decision is 'answer'",
236
+ )
237
+ return self
238
+
239
+ @model_validator(mode="after")
240
+ def _escalation_required_iff_decision_is_escalate(self) -> Self:
241
+ _require_iff(
242
+ condition=self.decision == DECISION_ESCALATE,
243
+ present=self.escalation is not None,
244
+ field="escalation",
245
+ rule="decision is 'escalate'",
246
+ )
247
+ return self
248
+
249
+
250
+ class Verdict(DomainModel):
251
+ """The Leader's `verify` report (post-rebase `conflicts` verify)."""
252
+
253
+ decision: Literal["accept", "changes_requested", "escalate", "supersede"]
254
+ summary: str
255
+ commit_subject: str = Field(
256
+ default="",
257
+ description=(
258
+ "Required and a concise subject following the target project's documented commit "
259
+ "convention, or Conventional Commits when none is documented, iff decision is 'accept' "
260
+ "(of the whole change; no task reference); otherwise empty."
261
+ ),
262
+ )
263
+ issues: list[Issue] = Field(
264
+ default_factory=list,
265
+ description="Required and non-empty iff decision is 'changes_requested'; otherwise empty.",
266
+ )
267
+ residual_risks: list[str] = Field(default_factory=list)
268
+ new_tasks: list[NewTask] = Field(
269
+ default_factory=list,
270
+ description="Required and non-empty iff decision is 'supersede'; otherwise empty.",
271
+ )
272
+ scope_changes: list[str] = Field(default_factory=list)
273
+ escalation: EscalationRequest | None = Field(
274
+ default=None, description="Required iff decision is 'escalate'; otherwise unset."
275
+ )
276
+
277
+ @model_validator(mode="after")
278
+ def _commit_subject_required_iff_accept(self) -> Self:
279
+ _require_iff(
280
+ condition=self.decision == DECISION_ACCEPT,
281
+ present=bool(self.commit_subject),
282
+ field="commit_subject",
283
+ rule="decision is 'accept'",
284
+ )
285
+ if self.commit_subject:
286
+ _require_commit_subject(self.commit_subject)
287
+ return self
288
+
289
+ @model_validator(mode="after")
290
+ def _issues_non_empty_iff_changes_requested(self) -> Self:
291
+ _require_iff(
292
+ condition=self.decision == DECISION_CHANGES_REQUESTED,
293
+ present=bool(self.issues),
294
+ field="issues",
295
+ rule="decision is 'changes_requested'",
296
+ )
297
+ return self
298
+
299
+ @model_validator(mode="after")
300
+ def _new_tasks_non_empty_iff_supersede(self) -> Self:
301
+ _require_iff(
302
+ condition=self.decision == DECISION_SUPERSEDE,
303
+ present=bool(self.new_tasks),
304
+ field="new_tasks",
305
+ rule="decision is 'supersede'",
306
+ )
307
+ _require_sibling_references_in_range(self.new_tasks, "new_tasks")
308
+ return self
309
+
310
+ @model_validator(mode="after")
311
+ def _escalation_required_iff_decision_is_escalate(self) -> Self:
312
+ _require_iff(
313
+ condition=self.decision == DECISION_ESCALATE,
314
+ present=self.escalation is not None,
315
+ field="escalation",
316
+ rule="decision is 'escalate'",
317
+ )
318
+ return self
319
+
320
+
321
+ class FeedbackAction(StrEnum):
322
+ """What the Leader decided to do about one source item of PR review feedback."""
323
+
324
+ ADDRESS = auto()
325
+ ALREADY_ADDRESSED = auto()
326
+ DISAGREE = auto()
327
+ NEEDS_OPERATOR = auto()
328
+
329
+
330
+ class FeedbackWork(DomainModel):
331
+ """One implementation instruction, possibly shared by several duplicate source items."""
332
+
333
+ id: _Text
334
+ instruction: _Text
335
+
336
+
337
+ class FeedbackDecision(DomainModel):
338
+ """The Leader's decision for one review body or original inline discussion."""
339
+
340
+ source_id: _Text
341
+ action: FeedbackAction
342
+ points: list[_Text] = Field(
343
+ min_length=1,
344
+ description="Every substantive point the source item raises, one entry each.",
345
+ )
346
+ detail: _Text = Field(
347
+ description=(
348
+ "address: the concrete work requested; already_addressed: the commit or file that "
349
+ "shows it; disagree: the specific reason; needs_operator: the question to ask."
350
+ )
351
+ )
352
+ work_id: str = Field(
353
+ default="", description="Required iff action is 'address': the id of a `work` entry."
354
+ )
355
+ reply: str = Field(
356
+ default="",
357
+ description="Proposed reply to this source item; required unless action is "
358
+ "'needs_operator', otherwise empty.",
359
+ )
360
+
361
+ @model_validator(mode="after")
362
+ def _work_id_required_iff_address(self) -> Self:
363
+ _require_iff(
364
+ condition=self.action is FeedbackAction.ADDRESS,
365
+ present=bool(self.work_id),
366
+ field="work_id",
367
+ rule="action is 'address'",
368
+ )
369
+ return self
370
+
371
+ @model_validator(mode="after")
372
+ def _reply_required_unless_needs_operator(self) -> Self:
373
+ _require_iff(
374
+ condition=self.action is not FeedbackAction.NEEDS_OPERATOR,
375
+ present=bool(self.reply.strip()),
376
+ field="reply",
377
+ rule="action is not 'needs_operator'",
378
+ )
379
+ return self
380
+
381
+
382
+ class FeedbackAnalysis(DomainModel):
383
+ """The Leader's `feedback_analysis` report: one decision per source item of a snapshot."""
384
+
385
+ summary: _Text
386
+ work: list[FeedbackWork] = Field(
387
+ default_factory=list,
388
+ description="Implementation instructions; one may serve several duplicate source items.",
389
+ )
390
+ decisions: list[FeedbackDecision] = Field(min_length=1)
391
+
392
+ @model_validator(mode="after")
393
+ def _identities_are_unique(self) -> Self:
394
+ _require_unique([decision.source_id for decision in self.decisions], "decisions")
395
+ _require_unique([work.id for work in self.work], "work")
396
+ return self
397
+
398
+ @model_validator(mode="after")
399
+ def _work_matches_address_decisions(self) -> Self:
400
+ work_ids = {work.id for work in self.work}
401
+ used = {
402
+ decision.work_id
403
+ for decision in self.decisions
404
+ if decision.action is FeedbackAction.ADDRESS
405
+ }
406
+ unknown = used - work_ids
407
+ if unknown:
408
+ message = f"decisions: work_id {sorted(unknown)} is not an id in `work`"
409
+ raise ValueError(message)
410
+ unused = work_ids - used
411
+ if unused:
412
+ message = f"work: {sorted(unused)} is not the work_id of any 'address' decision"
413
+ raise ValueError(message)
414
+ return self
415
+
416
+
417
+ class FeedbackRework(ImplementationReport):
418
+ """The Implementer's report on the accepted work of a feedback round."""
419
+
420
+
421
+ class VerifiedReply(DomainModel):
422
+ """The reply the Leader verified for one source item."""
423
+
424
+ source_id: _Text
425
+ reply: _Text
426
+
427
+
428
+ class FeedbackVerdict(Verdict):
429
+ """The Leader's verification of a feedback round's changes and replies."""
430
+
431
+ replies: list[VerifiedReply] = Field(
432
+ default_factory=list,
433
+ description=(
434
+ "Required and non-empty iff decision is 'accept': the final reply for every source "
435
+ "item whose decision has a reply; otherwise empty."
436
+ ),
437
+ )
438
+
439
+ @model_validator(mode="after")
440
+ def _decision_is_available(self) -> Self:
441
+ if self.decision == DECISION_SUPERSEDE:
442
+ message = "decision: 'supersede' is not available for feedback"
443
+ raise ValueError(message)
444
+ return self
445
+
446
+ @model_validator(mode="after")
447
+ def _replies_required_iff_accept(self) -> Self:
448
+ _require_iff(
449
+ condition=self.decision == DECISION_ACCEPT,
450
+ present=bool(self.replies),
451
+ field="replies",
452
+ rule="decision is 'accept'",
453
+ )
454
+ _require_unique([reply.source_id for reply in self.replies], "replies")
455
+ return self
456
+
457
+
458
+ class ReportKind(StrEnum):
459
+ """The `report.kind` discriminator naming a turn's report model."""
460
+
461
+ TRIAGE = auto()
462
+ IMPLEMENTATION = auto()
463
+ PLAN = auto()
464
+ CONSULT = auto()
465
+ VERDICT = auto()
466
+ FEEDBACK_ANALYSIS = auto()
467
+ FEEDBACK_REWORK = auto()
468
+ FEEDBACK_VERDICT = auto()
469
+
470
+
471
+ FEEDBACK_REPORT_KINDS: Final = frozenset(
472
+ {ReportKind.FEEDBACK_ANALYSIS, ReportKind.FEEDBACK_REWORK, ReportKind.FEEDBACK_VERDICT}
473
+ )
474
+
475
+ Report = (
476
+ TriageReport | ImplementationReport | PlanReport | ConsultAnswer | Verdict | FeedbackAnalysis
477
+ )
478
+
479
+ _MODELS_BY_KIND: dict[ReportKind, type[Report]] = {
480
+ ReportKind.TRIAGE: TriageReport,
481
+ ReportKind.IMPLEMENTATION: ImplementationReport,
482
+ ReportKind.PLAN: PlanReport,
483
+ ReportKind.CONSULT: ConsultAnswer,
484
+ ReportKind.VERDICT: Verdict,
485
+ ReportKind.FEEDBACK_ANALYSIS: FeedbackAnalysis,
486
+ ReportKind.FEEDBACK_REWORK: FeedbackRework,
487
+ ReportKind.FEEDBACK_VERDICT: FeedbackVerdict,
488
+ }
489
+
490
+
491
+ def model_for(kind: ReportKind) -> type[Report]:
492
+ """Return the pydantic model that validates reports of `kind`."""
493
+ return _MODELS_BY_KIND[kind]
494
+
495
+
496
+ def schema_for(kind: ReportKind) -> dict[str, Any]:
497
+ """Return the JSON Schema for `kind`'s report model, written as `report.schema.json`."""
498
+ return model_for(kind).model_json_schema()