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.
- vidbyte_cli/__init__.py +11 -0
- vidbyte_cli/__main__.py +6 -0
- vidbyte_cli/cli.py +18 -0
- vidbyte_cli/commands/__init__.py +86 -0
- vidbyte_cli/commands/agent_options.py +53 -0
- vidbyte_cli/commands/agents/__init__.py +31 -0
- vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
- vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
- vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
- vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
- vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
- vidbyte_cli/commands/agents/suggestion/render.py +77 -0
- vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
- vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
- vidbyte_cli/commands/auth/__init__.py +0 -0
- vidbyte_cli/commands/auth/login.py +100 -0
- vidbyte_cli/commands/auth/logout.py +41 -0
- vidbyte_cli/commands/auth/whoami.py +51 -0
- vidbyte_cli/commands/billing/__init__.py +20 -0
- vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
- vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
- vidbyte_cli/commands/billing/prompts/group.md +13 -0
- vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
- vidbyte_cli/commands/billing/prompts/library.py +18 -0
- vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
- vidbyte_cli/commands/billing/top_up.py +65 -0
- vidbyte_cli/commands/config/__init__.py +0 -0
- vidbyte_cli/commands/config/get.py +45 -0
- vidbyte_cli/commands/config/set.py +39 -0
- vidbyte_cli/commands/provider/__init__.py +9 -0
- vidbyte_cli/commands/provider/login.py +102 -0
- vidbyte_cli/commands/provider/logout.py +45 -0
- vidbyte_cli/commands/provider/whoami.py +49 -0
- vidbyte_cli/commands/research/__init__.py +5 -0
- vidbyte_cli/commands/research/add.py +41 -0
- vidbyte_cli/commands/research/options.py +134 -0
- vidbyte_cli/commands/research/render.py +157 -0
- vidbyte_cli/commands/research/resume.py +37 -0
- vidbyte_cli/commands/research/start.py +37 -0
- vidbyte_cli/commands/research/status.py +32 -0
- vidbyte_cli/commands/research/thread.py +30 -0
- vidbyte_cli/commands/research/threads.py +35 -0
- vidbyte_cli/commands/research/watch.py +93 -0
- vidbyte_cli/commands/runtime/__init__.py +23 -0
- vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
- vidbyte_cli/commands/runtime/doctor.py +44 -0
- vidbyte_cli/commands/runtime/list.py +47 -0
- vidbyte_cli/commands/runtime/persistence.py +141 -0
- vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
- vidbyte_cli/commands/runtime/stages.py +553 -0
- vidbyte_cli/commands/runtime/task_board.py +1342 -0
- vidbyte_cli/commands/setup/__init__.py +0 -0
- vidbyte_cli/commands/setup/doctor.py +64 -0
- vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
- vidbyte_cli/commands/setup/prompts/browser.md +7 -0
- vidbyte_cli/commands/setup/prompts/library.py +18 -0
- vidbyte_cli/commands/setup/prompts/star.md +13 -0
- vidbyte_cli/commands/setup/star.py +132 -0
- vidbyte_cli/lib/__init__.py +0 -0
- vidbyte_cli/lib/api/__init__.py +0 -0
- vidbyte_cli/lib/api/client.py +236 -0
- vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
- vidbyte_cli/lib/api/endpoints/auth.py +24 -0
- vidbyte_cli/lib/api/endpoints/billing.py +36 -0
- vidbyte_cli/lib/api/endpoints/research.py +103 -0
- vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
- vidbyte_cli/lib/api/problem.py +86 -0
- vidbyte_cli/lib/api/response.py +101 -0
- vidbyte_cli/lib/api/retry.py +116 -0
- vidbyte_cli/lib/api/runtime_payment.py +89 -0
- vidbyte_cli/lib/auth/__init__.py +16 -0
- vidbyte_cli/lib/auth/credentials.py +223 -0
- vidbyte_cli/lib/auth/input.py +59 -0
- vidbyte_cli/lib/auth/keyring_store.py +126 -0
- vidbyte_cli/lib/auth/provider_credentials.py +157 -0
- vidbyte_cli/lib/auth/provider_input.py +54 -0
- vidbyte_cli/lib/auth/provider_resolver.py +89 -0
- vidbyte_cli/lib/auth/provider_store.py +149 -0
- vidbyte_cli/lib/auth/provider_verifier.py +219 -0
- vidbyte_cli/lib/auth/resolver.py +63 -0
- vidbyte_cli/lib/auth/verifier.py +38 -0
- vidbyte_cli/lib/config/__init__.py +25 -0
- vidbyte_cli/lib/config/atomic.py +47 -0
- vidbyte_cli/lib/config/config.py +141 -0
- vidbyte_cli/lib/config/migration.py +81 -0
- vidbyte_cli/lib/config/models.py +145 -0
- vidbyte_cli/lib/config/paths.py +66 -0
- vidbyte_cli/lib/config/resolver.py +137 -0
- vidbyte_cli/lib/constants/__init__.py +1 -0
- vidbyte_cli/lib/constants/runtime.py +186 -0
- vidbyte_cli/lib/errors/__init__.py +12 -0
- vidbyte_cli/lib/errors/cli_error.py +86 -0
- vidbyte_cli/lib/errors/codes.py +43 -0
- vidbyte_cli/lib/errors/failures.py +2910 -0
- vidbyte_cli/lib/errors/handler.py +72 -0
- vidbyte_cli/lib/files/__init__.py +6 -0
- vidbyte_cli/lib/files/documents.py +94 -0
- vidbyte_cli/lib/files/store.py +156 -0
- vidbyte_cli/lib/io/__init__.py +16 -0
- vidbyte_cli/lib/io/attachments.py +129 -0
- vidbyte_cli/lib/io/codex_attachments.py +47 -0
- vidbyte_cli/lib/io/prompt.py +71 -0
- vidbyte_cli/lib/io/streams.py +37 -0
- vidbyte_cli/lib/io/terminal.py +66 -0
- vidbyte_cli/lib/output/__init__.py +7 -0
- vidbyte_cli/lib/output/formats.py +29 -0
- vidbyte_cli/lib/output/manager.py +95 -0
- vidbyte_cli/lib/output/models.py +49 -0
- vidbyte_cli/lib/runtime/__init__.py +9 -0
- vidbyte_cli/lib/runtime/application.py +134 -0
- vidbyte_cli/lib/runtime/context.py +250 -0
- vidbyte_cli/lib/runtime/options.py +159 -0
- vidbyte_cli/lib/runtime/version.py +30 -0
- vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
- vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
- vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
- vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
- vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
- vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
- vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
- vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
- vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
- vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
- vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
- vidbyte_cli/services/__init__.py +5 -0
- vidbyte_cli/services/ensemble/__init__.py +7 -0
- vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
- vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
- vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
- vidbyte_cli/services/ensemble/prompts/library.py +188 -0
- vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
- vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
- vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
- vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
- vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
- vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
- vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
- vidbyte_cli/services/ensemble/runner.py +30 -0
- vidbyte_cli/services/ensemble/sdk.py +134 -0
- vidbyte_cli/services/ensemble/service.py +233 -0
- vidbyte_cli/services/ensemble/settings.py +60 -0
- vidbyte_cli/services/persistence/__init__.py +6 -0
- vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
- vidbyte_cli/services/persistence/prompts/library.py +46 -0
- vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
- vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
- vidbyte_cli/services/persistence/runner.py +35 -0
- vidbyte_cli/services/persistence/session.py +116 -0
- vidbyte_cli/services/suggestions/__init__.py +7 -0
- vidbyte_cli/services/suggestions/categories.py +270 -0
- vidbyte_cli/services/suggestions/context.py +222 -0
- vidbyte_cli/services/suggestions/extra_compute.py +53 -0
- vidbyte_cli/services/suggestions/handoff.py +173 -0
- vidbyte_cli/services/suggestions/message_tools.py +47 -0
- vidbyte_cli/services/suggestions/project.py +253 -0
- vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
- vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
- vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
- vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
- vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
- vidbyte_cli/services/suggestions/prompts/library.py +72 -0
- vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
- vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
- vidbyte_cli/services/suggestions/sdk.py +228 -0
- vidbyte_cli/services/suggestions/selection.py +78 -0
- vidbyte_cli/services/suggestions/service.py +540 -0
- vidbyte_cli/types/__init__.py +0 -0
- vidbyte_cli/types/api.py +101 -0
- vidbyte_cli/types/attachments.py +79 -0
- vidbyte_cli/types/ensemble.py +189 -0
- vidbyte_cli/types/provider.py +62 -0
- vidbyte_cli/types/research.py +260 -0
- vidbyte_cli/types/runtime.py +1098 -0
- vidbyte_cli/types/suggestions.py +518 -0
- vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
- vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
- vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
- vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|