vidbyte-cli 0.1.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 (269) hide show
  1. vidbyte_cli/__init__.py +11 -0
  2. vidbyte_cli/__main__.py +6 -0
  3. vidbyte_cli/cli.py +18 -0
  4. vidbyte_cli/commands/__init__.py +86 -0
  5. vidbyte_cli/commands/agent_options.py +53 -0
  6. vidbyte_cli/commands/agents/__init__.py +31 -0
  7. vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
  8. vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
  9. vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
  10. vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
  11. vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
  12. vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
  13. vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
  14. vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
  15. vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
  16. vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
  17. vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
  18. vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
  19. vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
  20. vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
  21. vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
  22. vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
  23. vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
  24. vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
  25. vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
  26. vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
  27. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
  28. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
  29. vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
  30. vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
  31. vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
  32. vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
  33. vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
  34. vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
  35. vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
  36. vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
  37. vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
  38. vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
  39. vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
  40. vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
  41. vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
  42. vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
  43. vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
  44. vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
  45. vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
  46. vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
  47. vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
  48. vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
  49. vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
  50. vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
  51. vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
  52. vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
  53. vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
  54. vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
  55. vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
  56. vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
  57. vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
  58. vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
  59. vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
  60. vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
  61. vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
  62. vidbyte_cli/commands/agents/suggestion/render.py +77 -0
  63. vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
  64. vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
  65. vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
  66. vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
  67. vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
  68. vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
  69. vidbyte_cli/commands/auth/__init__.py +0 -0
  70. vidbyte_cli/commands/auth/login.py +100 -0
  71. vidbyte_cli/commands/auth/logout.py +41 -0
  72. vidbyte_cli/commands/auth/whoami.py +51 -0
  73. vidbyte_cli/commands/billing/__init__.py +20 -0
  74. vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
  75. vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
  76. vidbyte_cli/commands/billing/prompts/group.md +13 -0
  77. vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
  78. vidbyte_cli/commands/billing/prompts/library.py +18 -0
  79. vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
  80. vidbyte_cli/commands/billing/top_up.py +65 -0
  81. vidbyte_cli/commands/config/__init__.py +0 -0
  82. vidbyte_cli/commands/config/get.py +45 -0
  83. vidbyte_cli/commands/config/set.py +39 -0
  84. vidbyte_cli/commands/provider/__init__.py +9 -0
  85. vidbyte_cli/commands/provider/login.py +102 -0
  86. vidbyte_cli/commands/provider/logout.py +45 -0
  87. vidbyte_cli/commands/provider/whoami.py +49 -0
  88. vidbyte_cli/commands/research/__init__.py +5 -0
  89. vidbyte_cli/commands/research/add.py +41 -0
  90. vidbyte_cli/commands/research/options.py +134 -0
  91. vidbyte_cli/commands/research/render.py +157 -0
  92. vidbyte_cli/commands/research/resume.py +37 -0
  93. vidbyte_cli/commands/research/start.py +37 -0
  94. vidbyte_cli/commands/research/status.py +32 -0
  95. vidbyte_cli/commands/research/thread.py +30 -0
  96. vidbyte_cli/commands/research/threads.py +35 -0
  97. vidbyte_cli/commands/research/watch.py +93 -0
  98. vidbyte_cli/commands/runtime/__init__.py +23 -0
  99. vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
  100. vidbyte_cli/commands/runtime/doctor.py +44 -0
  101. vidbyte_cli/commands/runtime/list.py +47 -0
  102. vidbyte_cli/commands/runtime/persistence.py +141 -0
  103. vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
  104. vidbyte_cli/commands/runtime/stages.py +553 -0
  105. vidbyte_cli/commands/runtime/task_board.py +1342 -0
  106. vidbyte_cli/commands/setup/__init__.py +0 -0
  107. vidbyte_cli/commands/setup/doctor.py +64 -0
  108. vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
  109. vidbyte_cli/commands/setup/prompts/browser.md +7 -0
  110. vidbyte_cli/commands/setup/prompts/library.py +18 -0
  111. vidbyte_cli/commands/setup/prompts/star.md +13 -0
  112. vidbyte_cli/commands/setup/star.py +132 -0
  113. vidbyte_cli/lib/__init__.py +0 -0
  114. vidbyte_cli/lib/api/__init__.py +0 -0
  115. vidbyte_cli/lib/api/client.py +236 -0
  116. vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
  117. vidbyte_cli/lib/api/endpoints/auth.py +24 -0
  118. vidbyte_cli/lib/api/endpoints/billing.py +36 -0
  119. vidbyte_cli/lib/api/endpoints/research.py +103 -0
  120. vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
  121. vidbyte_cli/lib/api/problem.py +86 -0
  122. vidbyte_cli/lib/api/response.py +101 -0
  123. vidbyte_cli/lib/api/retry.py +116 -0
  124. vidbyte_cli/lib/api/runtime_payment.py +89 -0
  125. vidbyte_cli/lib/auth/__init__.py +16 -0
  126. vidbyte_cli/lib/auth/credentials.py +223 -0
  127. vidbyte_cli/lib/auth/input.py +59 -0
  128. vidbyte_cli/lib/auth/keyring_store.py +126 -0
  129. vidbyte_cli/lib/auth/provider_credentials.py +157 -0
  130. vidbyte_cli/lib/auth/provider_input.py +54 -0
  131. vidbyte_cli/lib/auth/provider_resolver.py +89 -0
  132. vidbyte_cli/lib/auth/provider_store.py +149 -0
  133. vidbyte_cli/lib/auth/provider_verifier.py +219 -0
  134. vidbyte_cli/lib/auth/resolver.py +63 -0
  135. vidbyte_cli/lib/auth/verifier.py +38 -0
  136. vidbyte_cli/lib/config/__init__.py +25 -0
  137. vidbyte_cli/lib/config/atomic.py +47 -0
  138. vidbyte_cli/lib/config/config.py +141 -0
  139. vidbyte_cli/lib/config/migration.py +81 -0
  140. vidbyte_cli/lib/config/models.py +145 -0
  141. vidbyte_cli/lib/config/paths.py +66 -0
  142. vidbyte_cli/lib/config/resolver.py +137 -0
  143. vidbyte_cli/lib/constants/__init__.py +1 -0
  144. vidbyte_cli/lib/constants/runtime.py +186 -0
  145. vidbyte_cli/lib/errors/__init__.py +12 -0
  146. vidbyte_cli/lib/errors/cli_error.py +86 -0
  147. vidbyte_cli/lib/errors/codes.py +43 -0
  148. vidbyte_cli/lib/errors/failures.py +2910 -0
  149. vidbyte_cli/lib/errors/handler.py +72 -0
  150. vidbyte_cli/lib/files/__init__.py +6 -0
  151. vidbyte_cli/lib/files/documents.py +94 -0
  152. vidbyte_cli/lib/files/store.py +156 -0
  153. vidbyte_cli/lib/io/__init__.py +16 -0
  154. vidbyte_cli/lib/io/attachments.py +129 -0
  155. vidbyte_cli/lib/io/codex_attachments.py +47 -0
  156. vidbyte_cli/lib/io/prompt.py +71 -0
  157. vidbyte_cli/lib/io/streams.py +37 -0
  158. vidbyte_cli/lib/io/terminal.py +66 -0
  159. vidbyte_cli/lib/output/__init__.py +7 -0
  160. vidbyte_cli/lib/output/formats.py +29 -0
  161. vidbyte_cli/lib/output/manager.py +95 -0
  162. vidbyte_cli/lib/output/models.py +49 -0
  163. vidbyte_cli/lib/runtime/__init__.py +9 -0
  164. vidbyte_cli/lib/runtime/application.py +134 -0
  165. vidbyte_cli/lib/runtime/context.py +250 -0
  166. vidbyte_cli/lib/runtime/options.py +159 -0
  167. vidbyte_cli/lib/runtime/version.py +30 -0
  168. vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
  169. vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
  170. vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
  171. vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
  172. vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
  173. vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
  174. vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
  175. vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
  176. vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
  177. vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
  178. vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
  179. vidbyte_cli/services/__init__.py +5 -0
  180. vidbyte_cli/services/ensemble/__init__.py +7 -0
  181. vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
  182. vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
  183. vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
  184. vidbyte_cli/services/ensemble/prompts/library.py +188 -0
  185. vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
  186. vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
  187. vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
  188. vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
  189. vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
  190. vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
  191. vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
  192. vidbyte_cli/services/ensemble/runner.py +30 -0
  193. vidbyte_cli/services/ensemble/sdk.py +134 -0
  194. vidbyte_cli/services/ensemble/service.py +233 -0
  195. vidbyte_cli/services/ensemble/settings.py +60 -0
  196. vidbyte_cli/services/persistence/__init__.py +6 -0
  197. vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
  198. vidbyte_cli/services/persistence/prompts/library.py +46 -0
  199. vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
  200. vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
  201. vidbyte_cli/services/persistence/runner.py +35 -0
  202. vidbyte_cli/services/persistence/session.py +116 -0
  203. vidbyte_cli/services/suggestions/__init__.py +7 -0
  204. vidbyte_cli/services/suggestions/categories.py +270 -0
  205. vidbyte_cli/services/suggestions/context.py +222 -0
  206. vidbyte_cli/services/suggestions/extra_compute.py +53 -0
  207. vidbyte_cli/services/suggestions/handoff.py +173 -0
  208. vidbyte_cli/services/suggestions/message_tools.py +47 -0
  209. vidbyte_cli/services/suggestions/project.py +253 -0
  210. vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
  211. vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
  212. vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
  213. vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
  214. vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
  215. vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
  216. vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
  217. vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
  218. vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
  219. vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
  220. vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
  221. vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
  222. vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
  223. vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
  224. vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
  225. vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
  226. vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
  227. vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
  228. vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
  229. vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
  230. vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
  231. vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
  232. vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
  233. vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
  234. vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
  235. vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
  236. vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
  237. vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
  238. vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
  239. vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
  240. vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
  241. vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
  242. vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
  243. vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
  244. vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
  245. vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
  246. vidbyte_cli/services/suggestions/prompts/library.py +72 -0
  247. vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
  248. vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
  249. vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
  250. vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
  251. vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
  252. vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
  253. vidbyte_cli/services/suggestions/sdk.py +228 -0
  254. vidbyte_cli/services/suggestions/selection.py +78 -0
  255. vidbyte_cli/services/suggestions/service.py +540 -0
  256. vidbyte_cli/types/__init__.py +0 -0
  257. vidbyte_cli/types/api.py +101 -0
  258. vidbyte_cli/types/attachments.py +79 -0
  259. vidbyte_cli/types/ensemble.py +189 -0
  260. vidbyte_cli/types/provider.py +62 -0
  261. vidbyte_cli/types/research.py +260 -0
  262. vidbyte_cli/types/runtime.py +1098 -0
  263. vidbyte_cli/types/suggestions.py +518 -0
  264. vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
  265. vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
  266. vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
  267. vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
  268. vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  269. vidbyte_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,473 @@
1
+ """One board's checkpoint directory: its layout, its manifest, and its stored step chain.
2
+
3
+ A board is addressable by an absolute directory, so several boards coexist under one root and
4
+ a caller can name the one it means. Every byte written here goes through `LocalFileStore`;
5
+ this class owns only board semantics — which file a step lives in, whether a stored board still
6
+ matches this invocation, how a fork copies a prefix, and how stored steps read back as a chain.
7
+ Nothing here touches the network; the backend never sees tasks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import os
14
+ import re
15
+ import subprocess
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+
19
+ from ...types.runtime import (
20
+ TaskBoardBoardSummary,
21
+ TaskBoardCheckpoint,
22
+ TaskBoardCheckpointChain,
23
+ TaskBoardIndex,
24
+ TaskBoardSettings,
25
+ TaskBoardUnreadableBoard,
26
+ )
27
+ from ..errors.cli_error import CliError
28
+ from ..errors.failures import (
29
+ LocalFileReadFailed,
30
+ TaskBoardBoardNotFound,
31
+ TaskBoardCheckpointMismatch,
32
+ TaskBoardCheckpointMissing,
33
+ TaskBoardForkPrefixIncomplete,
34
+ TaskBoardManifestUnreadable,
35
+ TaskBoardStepUnreadable,
36
+ )
37
+ from ..files import LocalFileStore
38
+
39
+ _BOARD_ID_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,64}")
40
+ _MANIFEST_NAME = "board.json"
41
+ _MANIFEST_VERSION = 2
42
+ _EXPORT_NAME = "progress.jsonl"
43
+ _HOOK_TIMEOUT_SECONDS = 60
44
+ # Fingerprint fields added after the first version-2 manifests were written. A manifest that
45
+ # predates them was necessarily a linear board with no links, so it still resumes as one.
46
+ _FINGERPRINT_DEFAULTS: dict[str, object] = {"execution_type": "linear", "dependencies": []}
47
+ # The run flag each fingerprint field is set by, with the value that needs no flag. A resume
48
+ # must repeat every non-default setting or validate_manifest rejects it, so the paste-able
49
+ # resume command carries them.
50
+ _RESUME_FLAGS: tuple[tuple[str, str, object], ...] = (
51
+ ("window", "--window", 10),
52
+ ("context_mode", "--context-mode", "windowed-summaries"),
53
+ ("summary_mode", "--summary-mode", "truncate-tail"),
54
+ ("summary_max_chars", "--summary-max-chars", 1200),
55
+ ("handoff_mode", "--handoff", "summary"),
56
+ ("execution_type", "--type", "linear"),
57
+ )
58
+
59
+
60
+ class TaskBoardCheckpointer:
61
+ """Writes, loads, validates, forks, and reports one board's checkpoint directory."""
62
+
63
+ def __init__(self, root: Path, board_id: str) -> None:
64
+ # Building the store resolves the directory without creating it; writes create parents.
65
+ self._store = LocalFileStore(root / board_id)
66
+ self._board_id = board_id
67
+
68
+ @property
69
+ def board_id(self) -> str:
70
+ # The directory name identifying this board inside its checkpoint root.
71
+ return self._board_id
72
+
73
+ @property
74
+ def directory(self) -> Path:
75
+ # Absolute, so the path a result reports still resolves from another working directory.
76
+ return self._store.root
77
+
78
+ @staticmethod
79
+ def board_id_for(tasks: tuple[str, ...]) -> str:
80
+ # Hashes the joined tasks so an unchanged board reuses one directory.
81
+ digest = hashlib.sha1("\x00".join(tasks).encode("utf-8")).hexdigest()
82
+ return digest[:12]
83
+
84
+ @staticmethod
85
+ def valid_id(board_id: str) -> bool:
86
+ # One character class for every board id, so a board can never escape its own root:
87
+ # no separator can appear, and an all-dots name would address the root or its parent.
88
+ return _BOARD_ID_PATTERN.fullmatch(board_id) is not None and set(board_id) != {"."}
89
+
90
+ @staticmethod
91
+ def timestamp() -> datetime:
92
+ # Single clock call site so tests can compare created_at without freezing time.
93
+ return datetime.now(UTC)
94
+
95
+ def step_name(self, index: int) -> str:
96
+ # One naming site for step files; the load glob and the fork copy both go through it.
97
+ return f"step-{index}.json"
98
+
99
+ def step_file(self, index: int) -> Path:
100
+ # Absolute path to one step, which is what `show-step` hands back to its caller.
101
+ return self._store.path_of(self.step_name(index))
102
+
103
+ def write_manifest(
104
+ self,
105
+ settings: TaskBoardSettings,
106
+ parent_board_id: str | None = None,
107
+ forked_at_index: int | None = None,
108
+ ) -> None:
109
+ # Stores the whole task list, not just its hash, so a later resume, status, fork, or
110
+ # export can address this board by id alone instead of re-supplying every task.
111
+ payload: dict[str, object] = {
112
+ "version": _MANIFEST_VERSION,
113
+ "board_id": self._board_id,
114
+ "tasks": list(settings.tasks),
115
+ "parent_board_id": parent_board_id,
116
+ "forked_at_index": forked_at_index,
117
+ **self._fingerprint(settings),
118
+ }
119
+ self._store.write_json(_MANIFEST_NAME, payload)
120
+
121
+ def read_manifest(self) -> dict[str, object]:
122
+ # A board with no manifest is not a board, and every read path fails closed here. An
123
+ # absent file and a broken one fail differently, because they have different repairs.
124
+ try:
125
+ manifest = self._store.read_json(_MANIFEST_NAME)
126
+ except LocalFileReadFailed as error:
127
+ raise TaskBoardManifestUnreadable(self._board_id, error.reason) from error
128
+ if manifest is None:
129
+ raise TaskBoardBoardNotFound(self._board_id)
130
+ if not isinstance(manifest, dict):
131
+ raise TaskBoardManifestUnreadable(self._board_id, "it is JSON but not an object")
132
+ return manifest
133
+
134
+ def validate_manifest(self, settings: TaskBoardSettings) -> None:
135
+ # Rejects a resumed board whose tasks or context settings differ from the stored run,
136
+ # naming the first divergence rather than merging two boards into one history.
137
+ try:
138
+ manifest = self.read_manifest()
139
+ except TaskBoardBoardNotFound as error:
140
+ raise TaskBoardCheckpointMismatch("manifest-unreadable") from error
141
+ for field, value in self._fingerprint(settings).items():
142
+ if manifest.get(field, _FINGERPRINT_DEFAULTS.get(field)) != value:
143
+ raise TaskBoardCheckpointMismatch(field)
144
+
145
+ def stored_tasks(self) -> tuple[str, ...]:
146
+ # The board as it was checkpointed, which is what makes a resume command paste-able.
147
+ tasks = self.read_manifest().get("tasks")
148
+ if not isinstance(tasks, list) or not tasks or not all(isinstance(t, str) for t in tasks):
149
+ raise TaskBoardManifestUnreadable(
150
+ self._board_id, "it holds no task list, as manifests before version 2 do not"
151
+ )
152
+ return tuple(str(task) for task in tasks)
153
+
154
+ def write_step(self, record: TaskBoardCheckpoint) -> Path:
155
+ # Persists one step atomically so a crash leaves at most one step unwritten.
156
+ return self._store.write_json(self.step_name(record.index), record.model_dump(mode="json"))
157
+
158
+ def read_step(self, index: int) -> TaskBoardCheckpoint:
159
+ # A gap fails loudly, because silently re-running a step is what resume exists to avoid.
160
+ # A file that exists but will not load is a different failure with a different repair.
161
+ try:
162
+ raw = self._store.read_text(self.step_name(index))
163
+ except LocalFileReadFailed as error:
164
+ raise TaskBoardStepUnreadable(self._board_id, index, error.reason) from error
165
+ if raw is None:
166
+ raise TaskBoardCheckpointMissing(index)
167
+ try:
168
+ return TaskBoardCheckpoint.model_validate_json(raw)
169
+ except ValueError as error:
170
+ raise TaskBoardStepUnreadable(
171
+ self._board_id, index, "it does not hold a valid step record"
172
+ ) from error
173
+
174
+ def load_prefix(
175
+ self, count: int, order: tuple[int, ...] | None = None
176
+ ) -> tuple[TaskBoardCheckpoint, ...]:
177
+ # Loads the first `count` steps of the execution order, which is board order unless a
178
+ # DAG order is given; any gap fails instead of re-executing.
179
+ indices = range(count) if order is None else order[:count]
180
+ return tuple(self.read_step(index) for index in indices)
181
+
182
+ def execution_order(self) -> tuple[int, ...]:
183
+ # The stored board's run order, rebuilt from its manifest exactly as TaskBoardSettings
184
+ # builds it, so status and fork count positions the same way a run does.
185
+ return self._order_of(self.read_manifest())
186
+
187
+ def load_stored(self, task_count: int) -> tuple[TaskBoardCheckpoint, ...]:
188
+ # Loads whatever exists without requiring a dense prefix, which is what a repair pass
189
+ # and every read-only verb need: a board can legitimately be sparse after a replay.
190
+ stored: list[TaskBoardCheckpoint] = []
191
+ for index in range(task_count):
192
+ if self._store.exists(self.step_name(index)):
193
+ stored.append(self.read_step(index))
194
+ return tuple(stored)
195
+
196
+ def chain(self) -> TaskBoardCheckpointChain:
197
+ # The stored steps as a linked list plus the fork edge to the board they branched from.
198
+ manifest = self.read_manifest()
199
+ task_count = self._task_count(manifest)
200
+ parent = manifest.get("parent_board_id")
201
+ forked = manifest.get("forked_at_index")
202
+ return TaskBoardCheckpointChain(
203
+ board_id=self._board_id,
204
+ board_dir=str(self.directory),
205
+ parent_board_id=parent if isinstance(parent, str) and parent else None,
206
+ forked_at_index=forked if isinstance(forked, int) else None,
207
+ task_count=task_count,
208
+ nodes=self.load_stored(task_count),
209
+ )
210
+
211
+ def summary(self) -> TaskBoardBoardSummary:
212
+ # One row of the board index, built from the same chain every other verb reads.
213
+ chain = self.chain()
214
+ return TaskBoardBoardSummary(
215
+ board_id=chain.board_id,
216
+ board_dir=chain.board_dir,
217
+ task_count=chain.task_count,
218
+ completed=len(chain.completed_indices),
219
+ failed=len(chain.failed_indices),
220
+ pending=len(chain.pending_indices),
221
+ parent_board_id=chain.parent_board_id,
222
+ )
223
+
224
+ @classmethod
225
+ def list_boards(cls, root: Path) -> TaskBoardIndex:
226
+ # Scans one checkpoint root. A directory with no manifest, or a name no verb could
227
+ # address, is an unrelated folder and is skipped. A board that has a manifest but will
228
+ # not load is reported with its own failure's wording instead, so one broken board
229
+ # neither fails the whole listing nor silently disappears from it.
230
+ boards: list[TaskBoardBoardSummary] = []
231
+ unreadable: list[TaskBoardUnreadableBoard] = []
232
+ for directory in LocalFileStore(root).subdirectories():
233
+ if not cls.valid_id(directory.name):
234
+ continue
235
+ board = cls(root, directory.name)
236
+ try:
237
+ boards.append(board.summary())
238
+ except TaskBoardBoardNotFound:
239
+ continue
240
+ except (
241
+ TaskBoardManifestUnreadable,
242
+ TaskBoardStepUnreadable,
243
+ TaskBoardCheckpointMissing,
244
+ ) as error:
245
+ unreadable.append(
246
+ TaskBoardUnreadableBoard(
247
+ board_id=board.board_id,
248
+ board_dir=str(board.directory),
249
+ problem=error.message,
250
+ hint=error.hint or "",
251
+ )
252
+ )
253
+ return TaskBoardIndex(boards=tuple(boards), unreadable=tuple(unreadable))
254
+
255
+ def resume_command(self, index: int, repair: bool = False) -> str:
256
+ # One rendering site for the paste-able continuation, so every verb that returns one
257
+ # returns the same shape. A board holding failures is repaired rather than resumed
258
+ # past, because --from would re-pay for the completed steps between them.
259
+ # `index` is a position in execution order, which for a DAG board is not a board index.
260
+ base = (
261
+ "vidbyte-cli runtime task-board run "
262
+ f"--checkpoint-root {self.directory.parent} --checkpoint-id {self._board_id}"
263
+ f"{self._settings_flags()}"
264
+ )
265
+ return f"{base} --from 0 --retry-failed-only" if repair else f"{base} --from {index}"
266
+
267
+ def export_path(self, configured: str) -> Path:
268
+ # An explicit path wins; otherwise the append-only log lives beside the step files.
269
+ return Path(configured).expanduser().resolve() if configured else self._export_default()
270
+
271
+ def append_export(self, record: TaskBoardCheckpoint, path: Path) -> Path:
272
+ # One line per step, in completion order, so a forking tool reads the prefix by tailing
273
+ # a single file rather than scanning and sorting a directory of step files.
274
+ return self._store.append_line(path, record.model_dump_json())
275
+
276
+ def write_report(self, chain: TaskBoardCheckpointChain, path: Path) -> Path:
277
+ # A board's history as prose, for the reader who will never open a JSON step file. The
278
+ # destination is absolute, so the board's own store writes it wherever it points.
279
+ target = Path(path).expanduser().resolve()
280
+ return self._store.write_text(target, self._report_body(chain))
281
+
282
+ def fork_into(self, target: TaskBoardCheckpointer, at_index: int) -> int:
283
+ # Copies the first at_index steps of the execution order into a new board and records
284
+ # the edge back to this one, so both histories stay independently readable and neither
285
+ # can overwrite the other. The whole prefix is proven present before the first copy, so
286
+ # a bad --at creates nothing, and the manifest is written last, so a copy that dies
287
+ # partway is never a board.
288
+ settings_manifest = self.read_manifest()
289
+ task_count = self._task_count(settings_manifest)
290
+ order = self._order_of(settings_manifest)
291
+ available = self._stored_prefix(order)
292
+ if at_index > available:
293
+ missing = order[available] if available < len(order) else available
294
+ raise TaskBoardForkPrefixIncomplete(
295
+ self._board_id, at_index, available, task_count, missing
296
+ )
297
+ copied = 0
298
+ for index in order[:at_index]:
299
+ self._store.copy_into(self.step_name(index), target.step_file(index))
300
+ copied += 1
301
+ forked: dict[str, object] = dict(settings_manifest)
302
+ forked["board_id"] = target.board_id
303
+ forked["parent_board_id"] = self._board_id
304
+ forked["forked_at_index"] = at_index
305
+ target.write_raw_manifest(forked)
306
+ return copied
307
+
308
+ def write_raw_manifest(self, payload: dict[str, object]) -> None:
309
+ # Fork is the only caller: it carries a validated manifest across from another board
310
+ # rather than rebuilding a fingerprint the source board already proved.
311
+ self._store.write_json(_MANIFEST_NAME, payload)
312
+
313
+ def run_hook(self, command: str, record: TaskBoardCheckpoint, step_file: Path) -> bool:
314
+ # The hook is an observer, never a gate: its failure is reported and the board keeps
315
+ # going, because a broken notification script must not cost a paid board its progress.
316
+ environment = dict(os.environ)
317
+ environment.update(
318
+ {
319
+ "BOARD_DIR": str(self.directory),
320
+ "BOARD_ID": self._board_id,
321
+ "STEP_FILE": str(step_file),
322
+ "STEP_INDEX": str(record.index),
323
+ "STEP_STATUS": record.status,
324
+ }
325
+ )
326
+ try:
327
+ finished = subprocess.run(
328
+ command,
329
+ shell=True,
330
+ cwd=str(self.directory),
331
+ env=environment,
332
+ timeout=_HOOK_TIMEOUT_SECONDS,
333
+ check=False,
334
+ capture_output=True,
335
+ )
336
+ except (OSError, subprocess.SubprocessError):
337
+ return False
338
+ return finished.returncode == 0
339
+
340
+ def _stored_prefix(self, order: tuple[int, ...]) -> int:
341
+ # Length of the unbroken run of stored steps from the start of the execution order,
342
+ # which is the largest valid fork point: a fork copies a dense prefix, never one with a
343
+ # hole in it.
344
+ for position, index in enumerate(order):
345
+ if not self._store.exists(self.step_name(index)):
346
+ return position
347
+ return len(order)
348
+
349
+ def _order_of(self, manifest: dict[str, object]) -> tuple[int, ...]:
350
+ # A linear board runs in board order. A DAG board's links are re-validated here, since
351
+ # a hand-edited manifest could otherwise make fork copy a prefix no run ever produced.
352
+ count = self._task_count(manifest)
353
+ kind = manifest.get("execution_type", _FINGERPRINT_DEFAULTS["execution_type"])
354
+ if kind != "dag":
355
+ return tuple(range(count))
356
+ links = self._links_of(manifest, count)
357
+ order = None if links is None else TaskBoardSettings.topological_order(count, links)
358
+ if order is None:
359
+ raise TaskBoardManifestUnreadable(
360
+ self._board_id, "its dependency links are not a valid acyclic graph"
361
+ )
362
+ return order
363
+
364
+ @staticmethod
365
+ def _links_of(manifest: dict[str, object], count: int) -> tuple[tuple[int, int], ...] | None:
366
+ # Stored links are [child, parent] pairs inside the board; anything else is damage.
367
+ raw = manifest.get("dependencies", [])
368
+ if not isinstance(raw, list):
369
+ return None
370
+ links: list[tuple[int, int]] = []
371
+ for pair in raw:
372
+ if not isinstance(pair, list) or len(pair) != 2:
373
+ return None
374
+ child, parent = pair
375
+ if not all(
376
+ isinstance(value, int) and not isinstance(value, bool) and 0 <= value < count
377
+ for value in (child, parent)
378
+ ):
379
+ return None
380
+ links.append((child, parent))
381
+ return tuple(links)
382
+
383
+ def _settings_flags(self) -> str:
384
+ # Every stored setting that differs from its default, rendered as the run flag that
385
+ # sets it. A manifest that cannot be read yields no flags: the resume it belongs to
386
+ # fails validation with its own, better-worded error anyway.
387
+ try:
388
+ manifest = self.read_manifest()
389
+ except CliError:
390
+ return ""
391
+ flags = [
392
+ f" {flag} {manifest[field]}"
393
+ for field, flag, default in _RESUME_FLAGS
394
+ if field in manifest and manifest[field] != default
395
+ ]
396
+ links = self._links_of(manifest, self._task_count(manifest)) or ()
397
+ parents: dict[int, list[int]] = {}
398
+ for child, parent in links:
399
+ parents.setdefault(child, []).append(parent)
400
+ flags.extend(
401
+ f" --depends-on {child}:{','.join(str(parent) for parent in sorted(parents[child]))}"
402
+ for child in sorted(parents)
403
+ )
404
+ return "".join(flags)
405
+
406
+ @staticmethod
407
+ def _task_count(manifest: dict[str, object]) -> int:
408
+ # A hand-edited or damaged count reads as an empty board rather than a crash, so the
409
+ # read verbs still report the board and its stored steps instead of failing outright.
410
+ count = manifest.get("task_count")
411
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
412
+ return 0
413
+ return count
414
+
415
+ def _export_default(self) -> Path:
416
+ # Keeps the log inside the board it describes when the caller names no path.
417
+ return self._store.path_of(_EXPORT_NAME)
418
+
419
+ def _fingerprint(self, settings: TaskBoardSettings) -> dict[str, object]:
420
+ # The settings a stored step's prompt actually depends on. Two runs that agree here
421
+ # produce the same prompts, which is exactly what resume and replay promise. Run
422
+ # controls such as the resume point, budgets, or the checkpoint mode are deliberately
423
+ # absent: they change per invocation, and would reject every legitimate resume.
424
+ return {
425
+ "task_hash": self.board_id_for(settings.tasks),
426
+ "task_count": len(settings.tasks),
427
+ "window": settings.window,
428
+ "context_mode": settings.context_mode.value,
429
+ "summary_mode": settings.summary_mode.value,
430
+ "summary_max_chars": settings.summary_max_chars,
431
+ "handoff_mode": settings.handoff_mode.value,
432
+ # A DAG step's prompt holds its dependencies' entries rather than a window, and
433
+ # the execution order decides which steps a resume or fork counts as the prefix.
434
+ "execution_type": settings.execution_type.value,
435
+ "dependencies": [[child, parent] for child, parent in settings.dependencies],
436
+ }
437
+
438
+ def _report_body(self, chain: TaskBoardCheckpointChain) -> str:
439
+ # Totals first, then one section per stored step in board order.
440
+ spend = sum(node.estimated_cost_usd or 0.0 for node in chain.nodes)
441
+ lines = [
442
+ f"# Task board {chain.board_id}",
443
+ "",
444
+ f"- Directory: {chain.board_dir}",
445
+ f"- Tasks: {chain.task_count}",
446
+ f"- Completed: {len(chain.completed_indices)}",
447
+ f"- Failed: {len(chain.failed_indices)}",
448
+ f"- Pending: {len(chain.pending_indices)}",
449
+ f"- Reported tokens: {chain.total_tokens}",
450
+ f"- Estimated cost: ${spend:.4f}",
451
+ ]
452
+ if chain.parent_board_id is not None:
453
+ lines.append(f"- Forked from {chain.parent_board_id} at step {chain.forked_at_index}")
454
+ for node in sorted(chain.nodes, key=lambda item: item.index):
455
+ tokens = "unreported" if node.total_tokens is None else str(node.total_tokens)
456
+ lines.extend(
457
+ [
458
+ "",
459
+ f"## Step {node.index} — {node.status}",
460
+ "",
461
+ f"- Thread: {node.thread_id}",
462
+ f"- Reported tokens: {tokens}",
463
+ "",
464
+ "### Task",
465
+ "",
466
+ node.task.strip(),
467
+ "",
468
+ "### Summary",
469
+ "",
470
+ node.summary.strip(),
471
+ ]
472
+ )
473
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,33 @@
1
+ # Task board agent instructions
2
+
3
+ ## Role
4
+
5
+ You are one task agent in an ordered task board. You complete exactly one task.
6
+
7
+ ## Goal
8
+
9
+ Finish the current task as completely as possible with the tools in this working directory.
10
+
11
+ ## Input contract
12
+
13
+ You receive the current task, and on a shared board also bounded summaries of prior tasks. Whatever you receive is the only prior context there is. Do not ask for full history.
14
+
15
+ ## Summary context contract
16
+
17
+ When a `Prior summaries` section is present, treat `[N]` labels as board order, expect earlier summaries to be truncated, and never invent missing truncated content. When that section is absent, the board is isolated: no earlier task exists as far as you are concerned, so do not assume prior work, prior files, or prior decisions, and do not ask what came before.
18
+
19
+ ## Output contract
20
+
21
+ Return the task outcome as final text. Keep it self-contained because the next agent reads only its summary, and on an isolated board reads nothing at all.
22
+
23
+ ## Stop conditions
24
+
25
+ Complete only the current task. Do not start later tasks or redo prior tasks.
26
+
27
+ ## Decompose contract
28
+
29
+ When your task prompt mentions `decompose_tool`, you are the only agent reading that task: no
30
+ sibling tasks or prior summaries reach you. Call the native tool only when the current task is
31
+ better handled as at least two ordered, self-contained child tasks; otherwise complete the task
32
+ normally. Each child runs in a fresh isolated agent and cannot decompose further, so every child
33
+ must contain the context it needs and must not depend on sibling work.
@@ -0,0 +1,111 @@
1
+ """Board tasks as files: one-task Markdown files, and whole-board task lists in and out.
2
+
3
+ This is the task board's own format knowledge — which suffixes are accepted, how a Markdown
4
+ list splits into tasks, and what a JSON board looks like. The bytes themselves go through
5
+ `LocalFileStore`, so reading and writing behave exactly like every other local file the CLI
6
+ keeps. What a task means, and how a board runs, is decided elsewhere.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from ..errors.failures import (
15
+ LocalFileReadFailed,
16
+ TaskBoardExportDestinationInvalid,
17
+ TaskBoardTaskFileInvalid,
18
+ TaskBoardTaskListInvalid,
19
+ )
20
+ from ..files import LocalFileStore
21
+
22
+ _MARKDOWN_SUFFIXES = (".md", ".markdown")
23
+ _JSON_SUFFIXES = (".json",)
24
+
25
+
26
+ class TaskBoardTaskFiles:
27
+ """Reads board tasks from caller files and writes a stored board back out as one file."""
28
+
29
+ def __init__(self) -> None:
30
+ # Rooted at the working directory, so a relative caller path means what the caller's
31
+ # shell meant by it; an absolute path passes through the store unchanged.
32
+ self._store = LocalFileStore(Path.cwd())
33
+
34
+ def read_task_file(self, path: Path) -> str:
35
+ # One whole Markdown file is one task, so its own line breaks are part of the task text
36
+ # and are never treated as task separators.
37
+ if path.suffix.lower() not in _MARKDOWN_SUFFIXES:
38
+ raise TaskBoardTaskFileInvalid()
39
+ try:
40
+ body = self._store.read_text(path)
41
+ except LocalFileReadFailed as error:
42
+ raise TaskBoardTaskFileInvalid() from error
43
+ text = "" if body is None else body.strip()
44
+ if not text:
45
+ raise TaskBoardTaskFileInvalid()
46
+ return text
47
+
48
+ def read_task_list(self, path: Path) -> tuple[str, ...]:
49
+ # A whole board in one reviewable file. Markdown splits on level-two headings and JSON
50
+ # is a flat array of strings; both keep board order exactly as the file lists it.
51
+ suffix = path.suffix.lower()
52
+ try:
53
+ body = self._store.read_text(path)
54
+ except LocalFileReadFailed as error:
55
+ raise TaskBoardTaskListInvalid() from error
56
+ if body is None:
57
+ raise TaskBoardTaskListInvalid()
58
+ if suffix in _JSON_SUFFIXES:
59
+ tasks = self._json_tasks(body)
60
+ elif suffix in _MARKDOWN_SUFFIXES:
61
+ tasks = self._markdown_tasks(body)
62
+ else:
63
+ raise TaskBoardTaskListInvalid()
64
+ if not tasks:
65
+ raise TaskBoardTaskListInvalid()
66
+ return tasks
67
+
68
+ def write_task_list(self, path: Path, tasks: tuple[str, ...]) -> Path:
69
+ # Export is the exact inverse of import, so a written board reloads to the same tuple.
70
+ # A write failure surfaces as the store's own failure for the caller to put in context.
71
+ suffix = path.suffix.lower()
72
+ if suffix in _JSON_SUFFIXES:
73
+ body = json.dumps(list(tasks), indent=2)
74
+ elif suffix in _MARKDOWN_SUFFIXES:
75
+ body = "".join(
76
+ f"## Task {index}\n\n{task.strip()}\n\n" for index, task in enumerate(tasks)
77
+ )
78
+ else:
79
+ raise TaskBoardExportDestinationInvalid()
80
+ return self._store.write_text(path, body)
81
+
82
+ @staticmethod
83
+ def _json_tasks(body: str) -> tuple[str, ...]:
84
+ # A board file has to be a flat array of task strings; anything else is a different
85
+ # document that would silently produce a board nobody wrote.
86
+ try:
87
+ parsed = json.loads(body)
88
+ except ValueError as error:
89
+ raise TaskBoardTaskListInvalid() from error
90
+ if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed):
91
+ raise TaskBoardTaskListInvalid()
92
+ return tuple(item.strip() for item in parsed if item.strip())
93
+
94
+ @staticmethod
95
+ def _markdown_tasks(body: str) -> tuple[str, ...]:
96
+ # Level-two headings are the separator, and the heading line itself is dropped, so a
97
+ # file's title and preamble above the first heading never become a task of their own.
98
+ tasks: list[str] = []
99
+ current: list[str] = []
100
+ started = False
101
+ for line in body.splitlines():
102
+ if line.startswith("## "):
103
+ if started and (task := "\n".join(current).strip()):
104
+ tasks.append(task)
105
+ started, current = True, []
106
+ continue
107
+ if started:
108
+ current.append(line)
109
+ if started and (task := "\n".join(current).strip()):
110
+ tasks.append(task)
111
+ return tuple(tasks)
@@ -0,0 +1,68 @@
1
+ """Strict offline verification for server-issued runtime receipts.
2
+
3
+ The execution flow verifies on the backend so its HMAC secret stays private.
4
+ This verifier remains for trusted integrations and contract diagnostics.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import hmac
12
+ from datetime import datetime
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+ _MAX_PAYLOAD_BYTES = 4096
17
+ _MAX_TOKEN_CHARACTERS = 8192
18
+ _MAX_TTL_SECONDS = 3600
19
+
20
+
21
+ class SignedRuntimeGrant(BaseModel):
22
+ """The complete signed payload; unknown fields are rejected."""
23
+
24
+ model_config = ConfigDict(extra="forbid", frozen=True)
25
+ admission_id: str = Field(min_length=1)
26
+ capability_id: str = Field(min_length=1)
27
+ version: str = Field(min_length=1)
28
+ user_id: str = Field(min_length=1)
29
+ api_key_id: str = Field(min_length=1)
30
+ charged_cents: int = Field(ge=1)
31
+ idempotency_key_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
32
+ admitted_at: datetime
33
+ expires_at: datetime
34
+
35
+
36
+ class RuntimeGrantVerifier:
37
+ """Verifies exact HMAC framing and parses a bounded, fully typed payload."""
38
+
39
+ def verify(self, token: str, signing_key: str, now: datetime) -> SignedRuntimeGrant:
40
+ # Size bounds precede decode; all time and identity fields are mandatory.
41
+ if not signing_key.strip() or len(token) > _MAX_TOKEN_CHARACTERS:
42
+ raise ValueError("Grant key missing or token too large.")
43
+ parts = token.split(".")
44
+ if len(parts) != 2 or len(parts[0]) > ((_MAX_PAYLOAD_BYTES + 2) // 3) * 4:
45
+ raise ValueError("Grant token malformed or payload too large.")
46
+ payload = self._decode(parts[0])
47
+ signature = self._decode(parts[1])
48
+ expected = hmac.new(signing_key.encode("utf-8"), payload, hashlib.sha256).digest()
49
+ if len(payload) > _MAX_PAYLOAD_BYTES or not hmac.compare_digest(expected, signature):
50
+ raise ValueError("Grant signature invalid or payload too large.")
51
+ parsed = SignedRuntimeGrant.model_validate_json(payload)
52
+ self._validate_time(parsed, now)
53
+ return parsed
54
+
55
+ def _validate_time(self, grant: SignedRuntimeGrant, now: datetime) -> None:
56
+ # Rejects absent timezones, future issuance, expiry and excessive lifetime.
57
+ start, end = grant.admitted_at, grant.expires_at
58
+ if start.tzinfo is None or end.tzinfo is None or now.tzinfo is None:
59
+ raise ValueError("Grant time must be timezone-aware.")
60
+ if not start <= now < end or (end - start).total_seconds() > _MAX_TTL_SECONDS:
61
+ raise ValueError("Grant time is invalid or expired.")
62
+
63
+ def _decode(self, value: str) -> bytes:
64
+ # Strict decoding rejects whitespace and characters ignored by permissive decoders.
65
+ try:
66
+ return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
67
+ except (ValueError, UnicodeError) as error:
68
+ raise ValueError("Invalid grant token encoding.") from error