shikiru-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Read recorded task evidence and write a versioned local export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC
|
|
9
|
+
from typing import TYPE_CHECKING, Literal, Protocol
|
|
10
|
+
|
|
11
|
+
from shikiru.agents.contracts import COST_BASIS_UNKNOWN
|
|
12
|
+
from shikiru.config.settings import MAX_TIMELINE_PAGE_SIZE, MAX_TIMELINE_SIGNAL_SCAN_ROWS
|
|
13
|
+
from shikiru.domain.evidence_export import (
|
|
14
|
+
EVIDENCE_EXPORT_FORMAT,
|
|
15
|
+
EVIDENCE_EXPORT_VERSION,
|
|
16
|
+
render_evidence_json,
|
|
17
|
+
render_evidence_markdown,
|
|
18
|
+
)
|
|
19
|
+
from shikiru.domain.runs import has_positive_token_evidence
|
|
20
|
+
from shikiru.domain.timeline import TIMELINE_ALL_CATEGORY, TIMELINE_ENGINE_CATEGORY
|
|
21
|
+
from shikiru.errors import ShikiruError
|
|
22
|
+
from shikiru.operator.queries import aggregate_usage
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from shikiru.clock import Clock
|
|
29
|
+
from shikiru.domain.runs import Run
|
|
30
|
+
from shikiru.domain.tasks import Task
|
|
31
|
+
from shikiru.operator.queries import (
|
|
32
|
+
OperatorQueries,
|
|
33
|
+
ReportRow,
|
|
34
|
+
TaskTimelineEntry,
|
|
35
|
+
)
|
|
36
|
+
from shikiru.store.tasks import TaskRelationships
|
|
37
|
+
|
|
38
|
+
EvidenceFormat = Literal["markdown", "json"]
|
|
39
|
+
EXPORT_FORMAT_MARKDOWN = "markdown"
|
|
40
|
+
EXPORT_FORMAT_JSON = "json"
|
|
41
|
+
TIMELINE_ORDER = "newest_first"
|
|
42
|
+
TIMELINE_SCOPE_ENGINE = "engine_events_and_decisions"
|
|
43
|
+
TIMELINE_SCOPE_WITH_SIGNALS = "engine_events_and_decisions_and_normalized_signals"
|
|
44
|
+
TIMELINE_NO_PROGRESS = "task evidence timeline cursor did not advance"
|
|
45
|
+
RELATIONSHIPS_CHANGED = "task relationships changed while preparing the export; retry"
|
|
46
|
+
TASK_MISSING = "task no longer exists"
|
|
47
|
+
DESTINATION_REQUIRED = "choose a destination path"
|
|
48
|
+
COST_BASIS_BILLED = "billed"
|
|
49
|
+
COST_BASIS_API_EQUIVALENT = "api_equivalent"
|
|
50
|
+
USAGE_PROVENANCE_LEGACY = "legacy_recorded"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EvidenceFileWriter(Protocol):
|
|
54
|
+
"""Local file boundary used by the read-only task evidence export use case."""
|
|
55
|
+
|
|
56
|
+
def write(
|
|
57
|
+
self,
|
|
58
|
+
destination: str,
|
|
59
|
+
content: str,
|
|
60
|
+
*,
|
|
61
|
+
overwrite: bool,
|
|
62
|
+
cancelled: threading.Event,
|
|
63
|
+
) -> Path: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TaskEvidenceExportError(ShikiruError):
|
|
67
|
+
"""A requested task evidence export cannot be assembled safely."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class TaskExportResult:
|
|
72
|
+
"""The completed local export destination and the evidence options used."""
|
|
73
|
+
|
|
74
|
+
destination: Path
|
|
75
|
+
format: EvidenceFormat
|
|
76
|
+
signals_included: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TaskEvidenceExporter:
|
|
80
|
+
"""Export one task's retained evidence without writing engine state."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
queries: OperatorQueries,
|
|
85
|
+
writer: EvidenceFileWriter,
|
|
86
|
+
clock: Clock,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Use the operator read queries, injected local writer and project clock."""
|
|
89
|
+
self._queries = queries
|
|
90
|
+
self._writer = writer
|
|
91
|
+
self._clock = clock
|
|
92
|
+
|
|
93
|
+
async def export(
|
|
94
|
+
self,
|
|
95
|
+
task_id: str,
|
|
96
|
+
destination: str,
|
|
97
|
+
*,
|
|
98
|
+
export_format: EvidenceFormat,
|
|
99
|
+
overwrite: bool,
|
|
100
|
+
include_signals: bool,
|
|
101
|
+
) -> TaskExportResult:
|
|
102
|
+
"""Collect and write one selected task's recorded evidence."""
|
|
103
|
+
if not destination.strip():
|
|
104
|
+
raise ValueError(DESTINATION_REQUIRED)
|
|
105
|
+
if export_format not in (EXPORT_FORMAT_MARKDOWN, EXPORT_FORMAT_JSON):
|
|
106
|
+
message = f"unsupported evidence export format: {export_format}"
|
|
107
|
+
raise ValueError(message)
|
|
108
|
+
task = self._queries.get_task_state(task_id)
|
|
109
|
+
if task is None:
|
|
110
|
+
message = f"{TASK_MISSING}: {task_id}"
|
|
111
|
+
raise TaskEvidenceExportError(message)
|
|
112
|
+
runs = self._queries.task_runs(task_id)
|
|
113
|
+
relationships = self._relationships(task_id)
|
|
114
|
+
files = await self._queries.task_export_files(task_id)
|
|
115
|
+
timeline = await self._timeline(task_id, include_signals=include_signals)
|
|
116
|
+
if self._queries.get_task_state(task_id) is None:
|
|
117
|
+
message = f"{TASK_MISSING}: {task_id}"
|
|
118
|
+
raise TaskEvidenceExportError(message)
|
|
119
|
+
exported_at = self._export_timestamp()
|
|
120
|
+
unavailable = _unavailable_sections(
|
|
121
|
+
files.reports,
|
|
122
|
+
files.facts,
|
|
123
|
+
files.errors,
|
|
124
|
+
timeline,
|
|
125
|
+
runs,
|
|
126
|
+
)
|
|
127
|
+
document = _document(
|
|
128
|
+
task=task,
|
|
129
|
+
relationships=relationships,
|
|
130
|
+
reports=files.reports,
|
|
131
|
+
facts=files.facts,
|
|
132
|
+
timeline=timeline,
|
|
133
|
+
runs=runs,
|
|
134
|
+
unavailable=unavailable,
|
|
135
|
+
exported_at=exported_at,
|
|
136
|
+
include_signals=include_signals,
|
|
137
|
+
)
|
|
138
|
+
content = (
|
|
139
|
+
render_evidence_markdown(document)
|
|
140
|
+
if export_format == EXPORT_FORMAT_MARKDOWN
|
|
141
|
+
else render_evidence_json(document)
|
|
142
|
+
)
|
|
143
|
+
cancelled = threading.Event()
|
|
144
|
+
try:
|
|
145
|
+
path = await asyncio.to_thread(
|
|
146
|
+
self._writer.write,
|
|
147
|
+
destination,
|
|
148
|
+
content,
|
|
149
|
+
overwrite=overwrite,
|
|
150
|
+
cancelled=cancelled,
|
|
151
|
+
)
|
|
152
|
+
except asyncio.CancelledError:
|
|
153
|
+
cancelled.set()
|
|
154
|
+
raise
|
|
155
|
+
return TaskExportResult(path, export_format, include_signals)
|
|
156
|
+
|
|
157
|
+
def _relationships(self, task_id: str) -> tuple[TaskRelationships, ...]:
|
|
158
|
+
pages: list[TaskRelationships] = []
|
|
159
|
+
offset = 0
|
|
160
|
+
while True:
|
|
161
|
+
page = self._queries.task_relationships(task_id, offset=offset)
|
|
162
|
+
if page is None:
|
|
163
|
+
message = f"{TASK_MISSING}: {task_id}"
|
|
164
|
+
raise TaskEvidenceExportError(message)
|
|
165
|
+
pages.append(page)
|
|
166
|
+
if not page.has_more:
|
|
167
|
+
_validate_relationship_counts(pages)
|
|
168
|
+
return tuple(pages)
|
|
169
|
+
next_offset = offset + page.page_size
|
|
170
|
+
if page.page_size <= 0 or next_offset <= offset:
|
|
171
|
+
raise TaskEvidenceExportError(TIMELINE_NO_PROGRESS)
|
|
172
|
+
offset = next_offset
|
|
173
|
+
|
|
174
|
+
async def _timeline(
|
|
175
|
+
self,
|
|
176
|
+
task_id: str,
|
|
177
|
+
*,
|
|
178
|
+
include_signals: bool,
|
|
179
|
+
) -> tuple[TaskTimelineEntry, ...]:
|
|
180
|
+
category = TIMELINE_ALL_CATEGORY if include_signals else TIMELINE_ENGINE_CATEGORY
|
|
181
|
+
entries: list[TaskTimelineEntry] = []
|
|
182
|
+
cursor: str | None = None
|
|
183
|
+
seen_cursors: set[str | None] = {cursor}
|
|
184
|
+
while True:
|
|
185
|
+
page = await self._queries.task_timeline(
|
|
186
|
+
task_id,
|
|
187
|
+
limit=MAX_TIMELINE_PAGE_SIZE,
|
|
188
|
+
cursor=cursor,
|
|
189
|
+
category=category,
|
|
190
|
+
scan_limit=MAX_TIMELINE_SIGNAL_SCAN_ROWS,
|
|
191
|
+
)
|
|
192
|
+
if page is None:
|
|
193
|
+
message = f"{TASK_MISSING}: {task_id}"
|
|
194
|
+
raise TaskEvidenceExportError(message)
|
|
195
|
+
entries.extend(page.entries)
|
|
196
|
+
if not page.has_older:
|
|
197
|
+
return tuple(entries)
|
|
198
|
+
cursor = page.older_cursor
|
|
199
|
+
if cursor is None or cursor in seen_cursors:
|
|
200
|
+
raise TaskEvidenceExportError(TIMELINE_NO_PROGRESS)
|
|
201
|
+
seen_cursors.add(cursor)
|
|
202
|
+
|
|
203
|
+
def _export_timestamp(self) -> str:
|
|
204
|
+
now = self._clock.now()
|
|
205
|
+
if now.tzinfo is None or now.utcoffset() is None:
|
|
206
|
+
message = "export clock returned a timestamp without a timezone"
|
|
207
|
+
raise TaskEvidenceExportError(message)
|
|
208
|
+
return now.astimezone(UTC).isoformat()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _document(
|
|
212
|
+
*,
|
|
213
|
+
task: Task,
|
|
214
|
+
relationships: Sequence[TaskRelationships],
|
|
215
|
+
reports: Sequence[ReportRow],
|
|
216
|
+
facts: Sequence[tuple[str, str]],
|
|
217
|
+
timeline: Sequence[TaskTimelineEntry],
|
|
218
|
+
runs: Sequence[Run],
|
|
219
|
+
unavailable: Sequence[dict[str, str]],
|
|
220
|
+
exported_at: str,
|
|
221
|
+
include_signals: bool,
|
|
222
|
+
) -> dict[str, object]:
|
|
223
|
+
return {
|
|
224
|
+
"format": EVIDENCE_EXPORT_FORMAT,
|
|
225
|
+
"version": EVIDENCE_EXPORT_VERSION,
|
|
226
|
+
"task_id": task.id,
|
|
227
|
+
"exported_at": exported_at,
|
|
228
|
+
"metadata": {
|
|
229
|
+
"format": EVIDENCE_EXPORT_FORMAT,
|
|
230
|
+
"version": EVIDENCE_EXPORT_VERSION,
|
|
231
|
+
"task_id": task.id,
|
|
232
|
+
"exported_at": exported_at,
|
|
233
|
+
"timeline_order": TIMELINE_ORDER,
|
|
234
|
+
"timeline_scope": (
|
|
235
|
+
TIMELINE_SCOPE_WITH_SIGNALS if include_signals else TIMELINE_SCOPE_ENGINE
|
|
236
|
+
),
|
|
237
|
+
"normalized_signals_included": include_signals,
|
|
238
|
+
"relationship_pages_exhausted": True,
|
|
239
|
+
"timeline_pages_exhausted": True,
|
|
240
|
+
},
|
|
241
|
+
"request": task.request,
|
|
242
|
+
"outcome": {
|
|
243
|
+
"status": task.status.value,
|
|
244
|
+
"reason": task.status_reason.value if task.status_reason is not None else None,
|
|
245
|
+
"summary": task.summary,
|
|
246
|
+
"wait_reason": task.wait_reason,
|
|
247
|
+
"holds": sorted(hold.value for hold in task.holds),
|
|
248
|
+
"created_at": task.created_at.isoformat(),
|
|
249
|
+
"started_at": task.started_at.isoformat() if task.started_at is not None else None,
|
|
250
|
+
"finished_at": task.finished_at.isoformat() if task.finished_at is not None else None,
|
|
251
|
+
},
|
|
252
|
+
"task": {
|
|
253
|
+
"title": task.title,
|
|
254
|
+
"mode": task.mode.value if task.mode is not None else None,
|
|
255
|
+
"step": task.step.value if task.step is not None else None,
|
|
256
|
+
"complexity": task.complexity,
|
|
257
|
+
"priority": task.priority,
|
|
258
|
+
"parent_id": task.parent_id,
|
|
259
|
+
"depends_on": list(task.depends_on),
|
|
260
|
+
"rung_index": {role.value: rung for role, rung in task.rung_index.items()},
|
|
261
|
+
"overrides": {
|
|
262
|
+
role.value: {
|
|
263
|
+
"agent": rung.agent,
|
|
264
|
+
"model": rung.model,
|
|
265
|
+
"effort": rung.effort,
|
|
266
|
+
}
|
|
267
|
+
for role, rung in task.overrides.items()
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
"relationships": _relationship_document(relationships),
|
|
271
|
+
"reports": [
|
|
272
|
+
{
|
|
273
|
+
"turn": report.turn,
|
|
274
|
+
"kind": report.kind.value,
|
|
275
|
+
"source": report.source,
|
|
276
|
+
"data": report.data.model_dump(mode="json"),
|
|
277
|
+
}
|
|
278
|
+
for report in reports
|
|
279
|
+
],
|
|
280
|
+
"facts": [{"turn": turn, "content": content} for turn, content in facts],
|
|
281
|
+
"timeline": [entry.model_dump(mode="json") for entry in timeline],
|
|
282
|
+
"sessions": [_run_document(run) for run in runs],
|
|
283
|
+
"usage": _usage_summary(runs),
|
|
284
|
+
"unavailable_sections": list(unavailable),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _relationship_document(pages: Sequence[TaskRelationships]) -> dict[str, object]:
|
|
289
|
+
first = pages[0]
|
|
290
|
+
return {
|
|
291
|
+
"family_parent": _task_reference(first.family_parent),
|
|
292
|
+
"family_root": _task_reference(first.family_root),
|
|
293
|
+
"family_children": [
|
|
294
|
+
_task_reference(task) for page in pages for task in page.family_children
|
|
295
|
+
],
|
|
296
|
+
"family_child_count": first.family_child_count,
|
|
297
|
+
"dependencies": [_task_reference(task) for page in pages for task in page.dependencies],
|
|
298
|
+
"dependency_count": first.dependency_count,
|
|
299
|
+
"blocking_dependencies": [
|
|
300
|
+
_task_reference(task) for page in pages for task in page.blocking_dependencies
|
|
301
|
+
],
|
|
302
|
+
"blocking_dependency_count": first.blocking_dependency_count,
|
|
303
|
+
"dependents": [_task_reference(task) for page in pages for task in page.dependents],
|
|
304
|
+
"dependent_count": first.dependent_count,
|
|
305
|
+
"replacements": [_task_reference(task) for page in pages for task in page.replacements],
|
|
306
|
+
"replacement_count": first.replacement_count,
|
|
307
|
+
"family_outcomes": [
|
|
308
|
+
{"outcome": outcome, "count": count} for outcome, count in first.family_outcomes
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_relationship_counts(pages: Sequence[TaskRelationships]) -> None:
|
|
314
|
+
first = pages[0]
|
|
315
|
+
counts = (
|
|
316
|
+
(first.family_child_count, sum(len(page.family_children) for page in pages)),
|
|
317
|
+
(first.dependency_count, sum(len(page.dependencies) for page in pages)),
|
|
318
|
+
(
|
|
319
|
+
first.blocking_dependency_count,
|
|
320
|
+
sum(len(page.blocking_dependencies) for page in pages),
|
|
321
|
+
),
|
|
322
|
+
(first.dependent_count, sum(len(page.dependents) for page in pages)),
|
|
323
|
+
(first.replacement_count, sum(len(page.replacements) for page in pages)),
|
|
324
|
+
)
|
|
325
|
+
if any(expected != observed for expected, observed in counts):
|
|
326
|
+
raise TaskEvidenceExportError(RELATIONSHIPS_CHANGED)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _task_reference(task: Task) -> dict[str, object]:
|
|
330
|
+
return {
|
|
331
|
+
"id": task.id,
|
|
332
|
+
"title": task.title,
|
|
333
|
+
"status": task.status.value,
|
|
334
|
+
"reason": task.status_reason.value if task.status_reason is not None else None,
|
|
335
|
+
"parent_id": task.parent_id,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _run_document(run: Run) -> dict[str, object]:
|
|
340
|
+
token_provenance = _token_source(run)
|
|
341
|
+
cost_source = _cost_source(run)
|
|
342
|
+
context_source = _context_source(run)
|
|
343
|
+
return {
|
|
344
|
+
"id": run.id,
|
|
345
|
+
"role": run.role.value,
|
|
346
|
+
"agent": run.agent,
|
|
347
|
+
"model": run.model,
|
|
348
|
+
"effort": run.effort,
|
|
349
|
+
"billing": {"mode": run.billing_mode, "source": run.billing_source},
|
|
350
|
+
"session_id": run.session_id,
|
|
351
|
+
"status": run.status.value,
|
|
352
|
+
"started_at": run.started_at.isoformat(),
|
|
353
|
+
"ended_at": run.ended_at.isoformat() if run.ended_at is not None else None,
|
|
354
|
+
"usage_provenance": {
|
|
355
|
+
"reported_marker": run.usage_reported,
|
|
356
|
+
"tokens": token_provenance,
|
|
357
|
+
"cost": cost_source,
|
|
358
|
+
"context": context_source,
|
|
359
|
+
},
|
|
360
|
+
"tokens": {
|
|
361
|
+
"input": run.usage.input_tokens if token_provenance != COST_BASIS_UNKNOWN else None,
|
|
362
|
+
"output": run.usage.output_tokens if token_provenance != COST_BASIS_UNKNOWN else None,
|
|
363
|
+
"cache_read": run.usage.cache_read_tokens
|
|
364
|
+
if token_provenance != COST_BASIS_UNKNOWN
|
|
365
|
+
else None,
|
|
366
|
+
"cache_write": run.usage.cache_write_tokens
|
|
367
|
+
if token_provenance != COST_BASIS_UNKNOWN
|
|
368
|
+
else None,
|
|
369
|
+
},
|
|
370
|
+
"cost": {
|
|
371
|
+
"usd": run.usage.cost_usd if cost_source != COST_BASIS_UNKNOWN else None,
|
|
372
|
+
"basis": run.usage.cost_basis,
|
|
373
|
+
},
|
|
374
|
+
"context_used_pct": (
|
|
375
|
+
run.usage.context_used_pct if context_source != COST_BASIS_UNKNOWN else None
|
|
376
|
+
),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _token_source(run: Run) -> str:
|
|
381
|
+
if run.usage_reported is True:
|
|
382
|
+
return "reported"
|
|
383
|
+
if run.usage_reported is None and has_positive_token_evidence(run):
|
|
384
|
+
return USAGE_PROVENANCE_LEGACY
|
|
385
|
+
return COST_BASIS_UNKNOWN
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _cost_source(run: Run) -> str:
|
|
389
|
+
if (
|
|
390
|
+
run.usage.cost_usd is None
|
|
391
|
+
or run.usage.cost_basis not in (COST_BASIS_BILLED, COST_BASIS_API_EQUIVALENT)
|
|
392
|
+
or run.usage_reported is False
|
|
393
|
+
):
|
|
394
|
+
return COST_BASIS_UNKNOWN
|
|
395
|
+
return "reported" if run.usage_reported is True else USAGE_PROVENANCE_LEGACY
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _context_source(run: Run) -> str:
|
|
399
|
+
if run.usage.context_used_pct is None or run.usage_reported is False:
|
|
400
|
+
return COST_BASIS_UNKNOWN
|
|
401
|
+
return "reported" if run.usage_reported is True else USAGE_PROVENANCE_LEGACY
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _usage_summary(runs: Sequence[Run]) -> list[dict[str, object]]:
|
|
405
|
+
summaries: list[dict[str, object]] = []
|
|
406
|
+
for usage in aggregate_usage(runs):
|
|
407
|
+
token_run_count = usage.usage_reported_runs + usage.legacy_token_runs
|
|
408
|
+
tokens: dict[str, int | None] = {
|
|
409
|
+
"input": usage.input_tokens if token_run_count else None,
|
|
410
|
+
"output": usage.output_tokens if token_run_count else None,
|
|
411
|
+
"cache_read": usage.cache_read_tokens if token_run_count else None,
|
|
412
|
+
"cache_write": usage.cache_write_tokens if token_run_count else None,
|
|
413
|
+
}
|
|
414
|
+
summaries.append(
|
|
415
|
+
{
|
|
416
|
+
"agent": usage.agent,
|
|
417
|
+
"run_count": usage.run_count,
|
|
418
|
+
"reported_token_runs": usage.usage_reported_runs,
|
|
419
|
+
"legacy_token_runs": usage.legacy_token_runs,
|
|
420
|
+
"unknown_token_runs": usage.run_count - token_run_count,
|
|
421
|
+
"tokens": tokens,
|
|
422
|
+
"billed_cost_usd": usage.billed_cost_usd,
|
|
423
|
+
"billed_cost_runs": usage.billed_cost_runs,
|
|
424
|
+
"legacy_billed_cost_runs": usage.legacy_billed_cost_runs,
|
|
425
|
+
"api_equivalent_cost_usd": usage.api_equivalent_cost_usd,
|
|
426
|
+
"api_equivalent_cost_runs": usage.api_equivalent_cost_runs,
|
|
427
|
+
"legacy_api_equivalent_cost_runs": usage.legacy_api_equivalent_cost_runs,
|
|
428
|
+
"unknown_cost_runs": usage.unknown_cost_runs,
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
return summaries
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _unavailable_sections(
|
|
435
|
+
reports: Sequence[ReportRow],
|
|
436
|
+
facts: Sequence[tuple[str, str]],
|
|
437
|
+
file_errors: Sequence[tuple[str, str]],
|
|
438
|
+
timeline: Sequence[TaskTimelineEntry],
|
|
439
|
+
runs: Sequence[Run],
|
|
440
|
+
) -> list[dict[str, str]]:
|
|
441
|
+
return [
|
|
442
|
+
*_missing_evidence_sections(reports, facts, timeline, runs),
|
|
443
|
+
*({"section": section, "reason": error} for section, error in file_errors),
|
|
444
|
+
*_timeline_unavailable_sections(timeline),
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _missing_evidence_sections(
|
|
449
|
+
reports: Sequence[ReportRow],
|
|
450
|
+
facts: Sequence[tuple[str, str]],
|
|
451
|
+
timeline: Sequence[TaskTimelineEntry],
|
|
452
|
+
runs: Sequence[Run],
|
|
453
|
+
) -> list[dict[str, str]]:
|
|
454
|
+
unavailable: list[dict[str, str]] = []
|
|
455
|
+
if not reports:
|
|
456
|
+
unavailable.append(
|
|
457
|
+
{
|
|
458
|
+
"section": "reports",
|
|
459
|
+
"reason": "No retained validated report or verdict is available.",
|
|
460
|
+
}
|
|
461
|
+
)
|
|
462
|
+
if not facts:
|
|
463
|
+
unavailable.append(
|
|
464
|
+
{"section": "facts", "reason": "No retained git facts file is available."}
|
|
465
|
+
)
|
|
466
|
+
if not runs:
|
|
467
|
+
unavailable.append(
|
|
468
|
+
{"section": "sessions", "reason": "No recorded task session is available."}
|
|
469
|
+
)
|
|
470
|
+
if not timeline:
|
|
471
|
+
unavailable.append(
|
|
472
|
+
{"section": "timeline", "reason": "No retained task timeline entries are available."}
|
|
473
|
+
)
|
|
474
|
+
return unavailable
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _timeline_unavailable_sections(timeline: Sequence[TaskTimelineEntry]) -> list[dict[str, str]]:
|
|
478
|
+
unavailable: list[dict[str, str]] = []
|
|
479
|
+
for entry in timeline:
|
|
480
|
+
if entry.identity.startswith("missing:"):
|
|
481
|
+
unavailable.append(
|
|
482
|
+
{
|
|
483
|
+
"section": "signals",
|
|
484
|
+
"reason": f"{entry.role or 'agent'} signal file is unavailable.",
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
elif entry.identity.startswith("unavailable:"):
|
|
488
|
+
unavailable.append({"section": "signals", "reason": entry.detail})
|
|
489
|
+
elif entry.incomplete:
|
|
490
|
+
unavailable.append(
|
|
491
|
+
{
|
|
492
|
+
"section": "signals",
|
|
493
|
+
"reason": entry.timestamp_error or "A signal row is incomplete.",
|
|
494
|
+
}
|
|
495
|
+
)
|
|
496
|
+
elif entry.timestamp_error is not None:
|
|
497
|
+
unavailable.append({"section": "timeline", "reason": entry.timestamp_error})
|
|
498
|
+
return unavailable
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Atomic local file writing for operator evidence exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
TEMP_FILE_PREFIX = ".shikiru-evidence-"
|
|
14
|
+
FILE_ENCODING = "utf-8"
|
|
15
|
+
FILE_MODE = 0o600
|
|
16
|
+
WRITE_CANCELLED_MESSAGE = "evidence export was cancelled before it was written"
|
|
17
|
+
CURRENT_DIRECTORY_NAME = "."
|
|
18
|
+
PARENT_DIRECTORY_NAME = ".."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LocalEvidenceWriter:
|
|
22
|
+
"""Write one export under an explicit local destination with atomic publication."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, project_root: Path) -> None:
|
|
25
|
+
"""Resolve relative destinations from the initialized project root."""
|
|
26
|
+
self._project_root = project_root
|
|
27
|
+
|
|
28
|
+
def write(
|
|
29
|
+
self,
|
|
30
|
+
destination: str,
|
|
31
|
+
content: str,
|
|
32
|
+
*,
|
|
33
|
+
overwrite: bool,
|
|
34
|
+
cancelled: threading.Event,
|
|
35
|
+
) -> Path:
|
|
36
|
+
"""Publish the complete UTF-8 export and return its resolved destination."""
|
|
37
|
+
path = self._destination(destination)
|
|
38
|
+
file_descriptor, temporary_name = tempfile.mkstemp(
|
|
39
|
+
prefix=TEMP_FILE_PREFIX,
|
|
40
|
+
dir=path.parent,
|
|
41
|
+
)
|
|
42
|
+
temporary_path = Path(temporary_name)
|
|
43
|
+
cancelled_before_publish = False
|
|
44
|
+
try:
|
|
45
|
+
with os.fdopen(file_descriptor, "w", encoding=FILE_ENCODING) as output:
|
|
46
|
+
os.fchmod(output.fileno(), FILE_MODE)
|
|
47
|
+
output.write(content)
|
|
48
|
+
output.flush()
|
|
49
|
+
os.fsync(output.fileno())
|
|
50
|
+
if cancelled.is_set():
|
|
51
|
+
cancelled_before_publish = True
|
|
52
|
+
elif overwrite:
|
|
53
|
+
temporary_path.replace(path)
|
|
54
|
+
else:
|
|
55
|
+
path.hardlink_to(temporary_path)
|
|
56
|
+
temporary_path.unlink()
|
|
57
|
+
except Exception as error:
|
|
58
|
+
try:
|
|
59
|
+
temporary_path.unlink(missing_ok=True)
|
|
60
|
+
except OSError as cleanup_error:
|
|
61
|
+
raise cleanup_error from error
|
|
62
|
+
raise
|
|
63
|
+
if cancelled_before_publish:
|
|
64
|
+
temporary_path.unlink(missing_ok=True)
|
|
65
|
+
raise InterruptedError(WRITE_CANCELLED_MESSAGE)
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
def _destination(self, destination: str) -> Path:
|
|
69
|
+
selected = Path(destination)
|
|
70
|
+
target = selected if selected.is_absolute() else self._project_root / selected
|
|
71
|
+
parent = target.parent.resolve(strict=True)
|
|
72
|
+
if not parent.is_dir():
|
|
73
|
+
message = f"destination parent is not a directory: {parent}"
|
|
74
|
+
raise NotADirectoryError(message)
|
|
75
|
+
if not target.name or target.name in {CURRENT_DIRECTORY_NAME, PARENT_DIRECTORY_NAME}:
|
|
76
|
+
message = f"destination must name a file: {target}"
|
|
77
|
+
raise ValueError(message)
|
|
78
|
+
return parent / target.name
|