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
+ """Read recorded task evidence and write a versioned local export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from datetime import UTC
9
+ from typing import TYPE_CHECKING, Literal, Protocol
10
+
11
+ from shikiru.agents.contracts import COST_BASIS_UNKNOWN
12
+ from shikiru.config.settings import MAX_TIMELINE_PAGE_SIZE, MAX_TIMELINE_SIGNAL_SCAN_ROWS
13
+ from shikiru.domain.evidence_export import (
14
+ EVIDENCE_EXPORT_FORMAT,
15
+ EVIDENCE_EXPORT_VERSION,
16
+ render_evidence_json,
17
+ render_evidence_markdown,
18
+ )
19
+ from shikiru.domain.runs import has_positive_token_evidence
20
+ from shikiru.domain.timeline import TIMELINE_ALL_CATEGORY, TIMELINE_ENGINE_CATEGORY
21
+ from shikiru.errors import ShikiruError
22
+ from shikiru.operator.queries import aggregate_usage
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Sequence
26
+ from pathlib import Path
27
+
28
+ from shikiru.clock import Clock
29
+ from shikiru.domain.runs import Run
30
+ from shikiru.domain.tasks import Task
31
+ from shikiru.operator.queries import (
32
+ OperatorQueries,
33
+ ReportRow,
34
+ TaskTimelineEntry,
35
+ )
36
+ from shikiru.store.tasks import TaskRelationships
37
+
38
+ EvidenceFormat = Literal["markdown", "json"]
39
+ EXPORT_FORMAT_MARKDOWN = "markdown"
40
+ EXPORT_FORMAT_JSON = "json"
41
+ TIMELINE_ORDER = "newest_first"
42
+ TIMELINE_SCOPE_ENGINE = "engine_events_and_decisions"
43
+ TIMELINE_SCOPE_WITH_SIGNALS = "engine_events_and_decisions_and_normalized_signals"
44
+ TIMELINE_NO_PROGRESS = "task evidence timeline cursor did not advance"
45
+ RELATIONSHIPS_CHANGED = "task relationships changed while preparing the export; retry"
46
+ TASK_MISSING = "task no longer exists"
47
+ DESTINATION_REQUIRED = "choose a destination path"
48
+ COST_BASIS_BILLED = "billed"
49
+ COST_BASIS_API_EQUIVALENT = "api_equivalent"
50
+ USAGE_PROVENANCE_LEGACY = "legacy_recorded"
51
+
52
+
53
+ class EvidenceFileWriter(Protocol):
54
+ """Local file boundary used by the read-only task evidence export use case."""
55
+
56
+ def write(
57
+ self,
58
+ destination: str,
59
+ content: str,
60
+ *,
61
+ overwrite: bool,
62
+ cancelled: threading.Event,
63
+ ) -> Path: ...
64
+
65
+
66
+ class TaskEvidenceExportError(ShikiruError):
67
+ """A requested task evidence export cannot be assembled safely."""
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class TaskExportResult:
72
+ """The completed local export destination and the evidence options used."""
73
+
74
+ destination: Path
75
+ format: EvidenceFormat
76
+ signals_included: bool
77
+
78
+
79
+ class TaskEvidenceExporter:
80
+ """Export one task's retained evidence without writing engine state."""
81
+
82
+ def __init__(
83
+ self,
84
+ queries: OperatorQueries,
85
+ writer: EvidenceFileWriter,
86
+ clock: Clock,
87
+ ) -> None:
88
+ """Use the operator read queries, injected local writer and project clock."""
89
+ self._queries = queries
90
+ self._writer = writer
91
+ self._clock = clock
92
+
93
+ async def export(
94
+ self,
95
+ task_id: str,
96
+ destination: str,
97
+ *,
98
+ export_format: EvidenceFormat,
99
+ overwrite: bool,
100
+ include_signals: bool,
101
+ ) -> TaskExportResult:
102
+ """Collect and write one selected task's recorded evidence."""
103
+ if not destination.strip():
104
+ raise ValueError(DESTINATION_REQUIRED)
105
+ if export_format not in (EXPORT_FORMAT_MARKDOWN, EXPORT_FORMAT_JSON):
106
+ message = f"unsupported evidence export format: {export_format}"
107
+ raise ValueError(message)
108
+ task = self._queries.get_task_state(task_id)
109
+ if task is None:
110
+ message = f"{TASK_MISSING}: {task_id}"
111
+ raise TaskEvidenceExportError(message)
112
+ runs = self._queries.task_runs(task_id)
113
+ relationships = self._relationships(task_id)
114
+ files = await self._queries.task_export_files(task_id)
115
+ timeline = await self._timeline(task_id, include_signals=include_signals)
116
+ if self._queries.get_task_state(task_id) is None:
117
+ message = f"{TASK_MISSING}: {task_id}"
118
+ raise TaskEvidenceExportError(message)
119
+ exported_at = self._export_timestamp()
120
+ unavailable = _unavailable_sections(
121
+ files.reports,
122
+ files.facts,
123
+ files.errors,
124
+ timeline,
125
+ runs,
126
+ )
127
+ document = _document(
128
+ task=task,
129
+ relationships=relationships,
130
+ reports=files.reports,
131
+ facts=files.facts,
132
+ timeline=timeline,
133
+ runs=runs,
134
+ unavailable=unavailable,
135
+ exported_at=exported_at,
136
+ include_signals=include_signals,
137
+ )
138
+ content = (
139
+ render_evidence_markdown(document)
140
+ if export_format == EXPORT_FORMAT_MARKDOWN
141
+ else render_evidence_json(document)
142
+ )
143
+ cancelled = threading.Event()
144
+ try:
145
+ path = await asyncio.to_thread(
146
+ self._writer.write,
147
+ destination,
148
+ content,
149
+ overwrite=overwrite,
150
+ cancelled=cancelled,
151
+ )
152
+ except asyncio.CancelledError:
153
+ cancelled.set()
154
+ raise
155
+ return TaskExportResult(path, export_format, include_signals)
156
+
157
+ def _relationships(self, task_id: str) -> tuple[TaskRelationships, ...]:
158
+ pages: list[TaskRelationships] = []
159
+ offset = 0
160
+ while True:
161
+ page = self._queries.task_relationships(task_id, offset=offset)
162
+ if page is None:
163
+ message = f"{TASK_MISSING}: {task_id}"
164
+ raise TaskEvidenceExportError(message)
165
+ pages.append(page)
166
+ if not page.has_more:
167
+ _validate_relationship_counts(pages)
168
+ return tuple(pages)
169
+ next_offset = offset + page.page_size
170
+ if page.page_size <= 0 or next_offset <= offset:
171
+ raise TaskEvidenceExportError(TIMELINE_NO_PROGRESS)
172
+ offset = next_offset
173
+
174
+ async def _timeline(
175
+ self,
176
+ task_id: str,
177
+ *,
178
+ include_signals: bool,
179
+ ) -> tuple[TaskTimelineEntry, ...]:
180
+ category = TIMELINE_ALL_CATEGORY if include_signals else TIMELINE_ENGINE_CATEGORY
181
+ entries: list[TaskTimelineEntry] = []
182
+ cursor: str | None = None
183
+ seen_cursors: set[str | None] = {cursor}
184
+ while True:
185
+ page = await self._queries.task_timeline(
186
+ task_id,
187
+ limit=MAX_TIMELINE_PAGE_SIZE,
188
+ cursor=cursor,
189
+ category=category,
190
+ scan_limit=MAX_TIMELINE_SIGNAL_SCAN_ROWS,
191
+ )
192
+ if page is None:
193
+ message = f"{TASK_MISSING}: {task_id}"
194
+ raise TaskEvidenceExportError(message)
195
+ entries.extend(page.entries)
196
+ if not page.has_older:
197
+ return tuple(entries)
198
+ cursor = page.older_cursor
199
+ if cursor is None or cursor in seen_cursors:
200
+ raise TaskEvidenceExportError(TIMELINE_NO_PROGRESS)
201
+ seen_cursors.add(cursor)
202
+
203
+ def _export_timestamp(self) -> str:
204
+ now = self._clock.now()
205
+ if now.tzinfo is None or now.utcoffset() is None:
206
+ message = "export clock returned a timestamp without a timezone"
207
+ raise TaskEvidenceExportError(message)
208
+ return now.astimezone(UTC).isoformat()
209
+
210
+
211
+ def _document(
212
+ *,
213
+ task: Task,
214
+ relationships: Sequence[TaskRelationships],
215
+ reports: Sequence[ReportRow],
216
+ facts: Sequence[tuple[str, str]],
217
+ timeline: Sequence[TaskTimelineEntry],
218
+ runs: Sequence[Run],
219
+ unavailable: Sequence[dict[str, str]],
220
+ exported_at: str,
221
+ include_signals: bool,
222
+ ) -> dict[str, object]:
223
+ return {
224
+ "format": EVIDENCE_EXPORT_FORMAT,
225
+ "version": EVIDENCE_EXPORT_VERSION,
226
+ "task_id": task.id,
227
+ "exported_at": exported_at,
228
+ "metadata": {
229
+ "format": EVIDENCE_EXPORT_FORMAT,
230
+ "version": EVIDENCE_EXPORT_VERSION,
231
+ "task_id": task.id,
232
+ "exported_at": exported_at,
233
+ "timeline_order": TIMELINE_ORDER,
234
+ "timeline_scope": (
235
+ TIMELINE_SCOPE_WITH_SIGNALS if include_signals else TIMELINE_SCOPE_ENGINE
236
+ ),
237
+ "normalized_signals_included": include_signals,
238
+ "relationship_pages_exhausted": True,
239
+ "timeline_pages_exhausted": True,
240
+ },
241
+ "request": task.request,
242
+ "outcome": {
243
+ "status": task.status.value,
244
+ "reason": task.status_reason.value if task.status_reason is not None else None,
245
+ "summary": task.summary,
246
+ "wait_reason": task.wait_reason,
247
+ "holds": sorted(hold.value for hold in task.holds),
248
+ "created_at": task.created_at.isoformat(),
249
+ "started_at": task.started_at.isoformat() if task.started_at is not None else None,
250
+ "finished_at": task.finished_at.isoformat() if task.finished_at is not None else None,
251
+ },
252
+ "task": {
253
+ "title": task.title,
254
+ "mode": task.mode.value if task.mode is not None else None,
255
+ "step": task.step.value if task.step is not None else None,
256
+ "complexity": task.complexity,
257
+ "priority": task.priority,
258
+ "parent_id": task.parent_id,
259
+ "depends_on": list(task.depends_on),
260
+ "rung_index": {role.value: rung for role, rung in task.rung_index.items()},
261
+ "overrides": {
262
+ role.value: {
263
+ "agent": rung.agent,
264
+ "model": rung.model,
265
+ "effort": rung.effort,
266
+ }
267
+ for role, rung in task.overrides.items()
268
+ },
269
+ },
270
+ "relationships": _relationship_document(relationships),
271
+ "reports": [
272
+ {
273
+ "turn": report.turn,
274
+ "kind": report.kind.value,
275
+ "source": report.source,
276
+ "data": report.data.model_dump(mode="json"),
277
+ }
278
+ for report in reports
279
+ ],
280
+ "facts": [{"turn": turn, "content": content} for turn, content in facts],
281
+ "timeline": [entry.model_dump(mode="json") for entry in timeline],
282
+ "sessions": [_run_document(run) for run in runs],
283
+ "usage": _usage_summary(runs),
284
+ "unavailable_sections": list(unavailable),
285
+ }
286
+
287
+
288
+ def _relationship_document(pages: Sequence[TaskRelationships]) -> dict[str, object]:
289
+ first = pages[0]
290
+ return {
291
+ "family_parent": _task_reference(first.family_parent),
292
+ "family_root": _task_reference(first.family_root),
293
+ "family_children": [
294
+ _task_reference(task) for page in pages for task in page.family_children
295
+ ],
296
+ "family_child_count": first.family_child_count,
297
+ "dependencies": [_task_reference(task) for page in pages for task in page.dependencies],
298
+ "dependency_count": first.dependency_count,
299
+ "blocking_dependencies": [
300
+ _task_reference(task) for page in pages for task in page.blocking_dependencies
301
+ ],
302
+ "blocking_dependency_count": first.blocking_dependency_count,
303
+ "dependents": [_task_reference(task) for page in pages for task in page.dependents],
304
+ "dependent_count": first.dependent_count,
305
+ "replacements": [_task_reference(task) for page in pages for task in page.replacements],
306
+ "replacement_count": first.replacement_count,
307
+ "family_outcomes": [
308
+ {"outcome": outcome, "count": count} for outcome, count in first.family_outcomes
309
+ ],
310
+ }
311
+
312
+
313
+ def _validate_relationship_counts(pages: Sequence[TaskRelationships]) -> None:
314
+ first = pages[0]
315
+ counts = (
316
+ (first.family_child_count, sum(len(page.family_children) for page in pages)),
317
+ (first.dependency_count, sum(len(page.dependencies) for page in pages)),
318
+ (
319
+ first.blocking_dependency_count,
320
+ sum(len(page.blocking_dependencies) for page in pages),
321
+ ),
322
+ (first.dependent_count, sum(len(page.dependents) for page in pages)),
323
+ (first.replacement_count, sum(len(page.replacements) for page in pages)),
324
+ )
325
+ if any(expected != observed for expected, observed in counts):
326
+ raise TaskEvidenceExportError(RELATIONSHIPS_CHANGED)
327
+
328
+
329
+ def _task_reference(task: Task) -> dict[str, object]:
330
+ return {
331
+ "id": task.id,
332
+ "title": task.title,
333
+ "status": task.status.value,
334
+ "reason": task.status_reason.value if task.status_reason is not None else None,
335
+ "parent_id": task.parent_id,
336
+ }
337
+
338
+
339
+ def _run_document(run: Run) -> dict[str, object]:
340
+ token_provenance = _token_source(run)
341
+ cost_source = _cost_source(run)
342
+ context_source = _context_source(run)
343
+ return {
344
+ "id": run.id,
345
+ "role": run.role.value,
346
+ "agent": run.agent,
347
+ "model": run.model,
348
+ "effort": run.effort,
349
+ "billing": {"mode": run.billing_mode, "source": run.billing_source},
350
+ "session_id": run.session_id,
351
+ "status": run.status.value,
352
+ "started_at": run.started_at.isoformat(),
353
+ "ended_at": run.ended_at.isoformat() if run.ended_at is not None else None,
354
+ "usage_provenance": {
355
+ "reported_marker": run.usage_reported,
356
+ "tokens": token_provenance,
357
+ "cost": cost_source,
358
+ "context": context_source,
359
+ },
360
+ "tokens": {
361
+ "input": run.usage.input_tokens if token_provenance != COST_BASIS_UNKNOWN else None,
362
+ "output": run.usage.output_tokens if token_provenance != COST_BASIS_UNKNOWN else None,
363
+ "cache_read": run.usage.cache_read_tokens
364
+ if token_provenance != COST_BASIS_UNKNOWN
365
+ else None,
366
+ "cache_write": run.usage.cache_write_tokens
367
+ if token_provenance != COST_BASIS_UNKNOWN
368
+ else None,
369
+ },
370
+ "cost": {
371
+ "usd": run.usage.cost_usd if cost_source != COST_BASIS_UNKNOWN else None,
372
+ "basis": run.usage.cost_basis,
373
+ },
374
+ "context_used_pct": (
375
+ run.usage.context_used_pct if context_source != COST_BASIS_UNKNOWN else None
376
+ ),
377
+ }
378
+
379
+
380
+ def _token_source(run: Run) -> str:
381
+ if run.usage_reported is True:
382
+ return "reported"
383
+ if run.usage_reported is None and has_positive_token_evidence(run):
384
+ return USAGE_PROVENANCE_LEGACY
385
+ return COST_BASIS_UNKNOWN
386
+
387
+
388
+ def _cost_source(run: Run) -> str:
389
+ if (
390
+ run.usage.cost_usd is None
391
+ or run.usage.cost_basis not in (COST_BASIS_BILLED, COST_BASIS_API_EQUIVALENT)
392
+ or run.usage_reported is False
393
+ ):
394
+ return COST_BASIS_UNKNOWN
395
+ return "reported" if run.usage_reported is True else USAGE_PROVENANCE_LEGACY
396
+
397
+
398
+ def _context_source(run: Run) -> str:
399
+ if run.usage.context_used_pct is None or run.usage_reported is False:
400
+ return COST_BASIS_UNKNOWN
401
+ return "reported" if run.usage_reported is True else USAGE_PROVENANCE_LEGACY
402
+
403
+
404
+ def _usage_summary(runs: Sequence[Run]) -> list[dict[str, object]]:
405
+ summaries: list[dict[str, object]] = []
406
+ for usage in aggregate_usage(runs):
407
+ token_run_count = usage.usage_reported_runs + usage.legacy_token_runs
408
+ tokens: dict[str, int | None] = {
409
+ "input": usage.input_tokens if token_run_count else None,
410
+ "output": usage.output_tokens if token_run_count else None,
411
+ "cache_read": usage.cache_read_tokens if token_run_count else None,
412
+ "cache_write": usage.cache_write_tokens if token_run_count else None,
413
+ }
414
+ summaries.append(
415
+ {
416
+ "agent": usage.agent,
417
+ "run_count": usage.run_count,
418
+ "reported_token_runs": usage.usage_reported_runs,
419
+ "legacy_token_runs": usage.legacy_token_runs,
420
+ "unknown_token_runs": usage.run_count - token_run_count,
421
+ "tokens": tokens,
422
+ "billed_cost_usd": usage.billed_cost_usd,
423
+ "billed_cost_runs": usage.billed_cost_runs,
424
+ "legacy_billed_cost_runs": usage.legacy_billed_cost_runs,
425
+ "api_equivalent_cost_usd": usage.api_equivalent_cost_usd,
426
+ "api_equivalent_cost_runs": usage.api_equivalent_cost_runs,
427
+ "legacy_api_equivalent_cost_runs": usage.legacy_api_equivalent_cost_runs,
428
+ "unknown_cost_runs": usage.unknown_cost_runs,
429
+ }
430
+ )
431
+ return summaries
432
+
433
+
434
+ def _unavailable_sections(
435
+ reports: Sequence[ReportRow],
436
+ facts: Sequence[tuple[str, str]],
437
+ file_errors: Sequence[tuple[str, str]],
438
+ timeline: Sequence[TaskTimelineEntry],
439
+ runs: Sequence[Run],
440
+ ) -> list[dict[str, str]]:
441
+ return [
442
+ *_missing_evidence_sections(reports, facts, timeline, runs),
443
+ *({"section": section, "reason": error} for section, error in file_errors),
444
+ *_timeline_unavailable_sections(timeline),
445
+ ]
446
+
447
+
448
+ def _missing_evidence_sections(
449
+ reports: Sequence[ReportRow],
450
+ facts: Sequence[tuple[str, str]],
451
+ timeline: Sequence[TaskTimelineEntry],
452
+ runs: Sequence[Run],
453
+ ) -> list[dict[str, str]]:
454
+ unavailable: list[dict[str, str]] = []
455
+ if not reports:
456
+ unavailable.append(
457
+ {
458
+ "section": "reports",
459
+ "reason": "No retained validated report or verdict is available.",
460
+ }
461
+ )
462
+ if not facts:
463
+ unavailable.append(
464
+ {"section": "facts", "reason": "No retained git facts file is available."}
465
+ )
466
+ if not runs:
467
+ unavailable.append(
468
+ {"section": "sessions", "reason": "No recorded task session is available."}
469
+ )
470
+ if not timeline:
471
+ unavailable.append(
472
+ {"section": "timeline", "reason": "No retained task timeline entries are available."}
473
+ )
474
+ return unavailable
475
+
476
+
477
+ def _timeline_unavailable_sections(timeline: Sequence[TaskTimelineEntry]) -> list[dict[str, str]]:
478
+ unavailable: list[dict[str, str]] = []
479
+ for entry in timeline:
480
+ if entry.identity.startswith("missing:"):
481
+ unavailable.append(
482
+ {
483
+ "section": "signals",
484
+ "reason": f"{entry.role or 'agent'} signal file is unavailable.",
485
+ }
486
+ )
487
+ elif entry.identity.startswith("unavailable:"):
488
+ unavailable.append({"section": "signals", "reason": entry.detail})
489
+ elif entry.incomplete:
490
+ unavailable.append(
491
+ {
492
+ "section": "signals",
493
+ "reason": entry.timestamp_error or "A signal row is incomplete.",
494
+ }
495
+ )
496
+ elif entry.timestamp_error is not None:
497
+ unavailable.append({"section": "timeline", "reason": entry.timestamp_error})
498
+ return unavailable
@@ -0,0 +1,78 @@
1
+ """Atomic local file writing for operator evidence exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ import threading
12
+
13
+ TEMP_FILE_PREFIX = ".shikiru-evidence-"
14
+ FILE_ENCODING = "utf-8"
15
+ FILE_MODE = 0o600
16
+ WRITE_CANCELLED_MESSAGE = "evidence export was cancelled before it was written"
17
+ CURRENT_DIRECTORY_NAME = "."
18
+ PARENT_DIRECTORY_NAME = ".."
19
+
20
+
21
+ class LocalEvidenceWriter:
22
+ """Write one export under an explicit local destination with atomic publication."""
23
+
24
+ def __init__(self, project_root: Path) -> None:
25
+ """Resolve relative destinations from the initialized project root."""
26
+ self._project_root = project_root
27
+
28
+ def write(
29
+ self,
30
+ destination: str,
31
+ content: str,
32
+ *,
33
+ overwrite: bool,
34
+ cancelled: threading.Event,
35
+ ) -> Path:
36
+ """Publish the complete UTF-8 export and return its resolved destination."""
37
+ path = self._destination(destination)
38
+ file_descriptor, temporary_name = tempfile.mkstemp(
39
+ prefix=TEMP_FILE_PREFIX,
40
+ dir=path.parent,
41
+ )
42
+ temporary_path = Path(temporary_name)
43
+ cancelled_before_publish = False
44
+ try:
45
+ with os.fdopen(file_descriptor, "w", encoding=FILE_ENCODING) as output:
46
+ os.fchmod(output.fileno(), FILE_MODE)
47
+ output.write(content)
48
+ output.flush()
49
+ os.fsync(output.fileno())
50
+ if cancelled.is_set():
51
+ cancelled_before_publish = True
52
+ elif overwrite:
53
+ temporary_path.replace(path)
54
+ else:
55
+ path.hardlink_to(temporary_path)
56
+ temporary_path.unlink()
57
+ except Exception as error:
58
+ try:
59
+ temporary_path.unlink(missing_ok=True)
60
+ except OSError as cleanup_error:
61
+ raise cleanup_error from error
62
+ raise
63
+ if cancelled_before_publish:
64
+ temporary_path.unlink(missing_ok=True)
65
+ raise InterruptedError(WRITE_CANCELLED_MESSAGE)
66
+ return path
67
+
68
+ def _destination(self, destination: str) -> Path:
69
+ selected = Path(destination)
70
+ target = selected if selected.is_absolute() else self._project_root / selected
71
+ parent = target.parent.resolve(strict=True)
72
+ if not parent.is_dir():
73
+ message = f"destination parent is not a directory: {parent}"
74
+ raise NotADirectoryError(message)
75
+ if not target.name or target.name in {CURRENT_DIRECTORY_NAME, PARENT_DIRECTORY_NAME}:
76
+ message = f"destination must name a file: {target}"
77
+ raise ValueError(message)
78
+ return parent / target.name