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,160 @@
|
|
|
1
|
+
"""Where task roots start from: the local or the verified fetched remote base."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol
|
|
7
|
+
|
|
8
|
+
from shikiru.errors import CommandFailed, CommandTimeoutError, ShikiruError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from shikiru.workspace.git import Git
|
|
14
|
+
|
|
15
|
+
BASE_REF_PREFIX = "refs/shikiru/bases/"
|
|
16
|
+
_HEADS_PREFIX = "refs/heads/"
|
|
17
|
+
_REMOTE_TRACKING_PREFIX = "refs/remotes/"
|
|
18
|
+
_MISSING_REF_SHA = "0" * 40
|
|
19
|
+
_DIVERGED_REASON = "it has local commits the remote base does not contain"
|
|
20
|
+
_NOT_MOVED_REASON = "git left the branch where it was"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MergedBaseUnavailable(ShikiruError):
|
|
24
|
+
"""A merged pull request's commit is not on the fetched remote base."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, base: str, commit: str | None) -> None:
|
|
27
|
+
"""Name the base and the merge commit that could not be verified on it."""
|
|
28
|
+
super().__init__(f"merge commit {commit} is not on the fetched remote base {base}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BaseFetchFailed(ShikiruError):
|
|
32
|
+
"""The remote base could not be fetched: the remote is unreachable or the fetch timed out."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, base: str, remote: str, cause: ShikiruError) -> None:
|
|
35
|
+
"""Name the `base` and `remote` and say why the fetch failed."""
|
|
36
|
+
super().__init__(f"cannot fetch {base} from {remote}: {cause}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LocalBaseBlocked(ShikiruError):
|
|
40
|
+
"""The local base branch cannot fast-forward to the remote tip without losing work."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, base: str, tip: str, reason: str) -> None:
|
|
43
|
+
"""Name the `base`, the remote `tip` it could not reach and why."""
|
|
44
|
+
super().__init__(f"local base {base} cannot fast-forward to {tip}: {reason}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaseRefs(Protocol):
|
|
48
|
+
"""The single rule choosing the git ref a task root starts from."""
|
|
49
|
+
|
|
50
|
+
async def resolve(self, base: str) -> str:
|
|
51
|
+
"""Return the ref new task roots of `base` start from and rebase onto."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LocalBaseRefs:
|
|
56
|
+
"""`integration = "merge"`: work lands on the local base branch, so it is the ref."""
|
|
57
|
+
|
|
58
|
+
async def resolve(self, base: str) -> str:
|
|
59
|
+
"""Return `base` itself."""
|
|
60
|
+
return base
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FetchedBaseRefs:
|
|
64
|
+
"""`integration = "pr"`: work lands on the remote base, which the engine fetches.
|
|
65
|
+
|
|
66
|
+
Every root starts from the freshly fetched remote base, so local commits that were never
|
|
67
|
+
pushed are not in it.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, git: Git, project_root: Path, remote: str, *, fetch_timeout: float) -> None:
|
|
71
|
+
"""Fetch from `remote` into Shikiru's own refs of the repository at `project_root`.
|
|
72
|
+
|
|
73
|
+
A fetch that takes longer than `fetch_timeout` seconds fails.
|
|
74
|
+
"""
|
|
75
|
+
self._git = git
|
|
76
|
+
self._project_root = project_root
|
|
77
|
+
self._remote = remote
|
|
78
|
+
self._fetch_timeout = fetch_timeout
|
|
79
|
+
|
|
80
|
+
async def resolve(self, base: str) -> str:
|
|
81
|
+
"""Fetch the remote `base` and return the ref it was fetched into.
|
|
82
|
+
|
|
83
|
+
Raises `BaseFetchFailed` when the fetch fails: a root is never built from a stale ref.
|
|
84
|
+
"""
|
|
85
|
+
return await self._fetch(base)
|
|
86
|
+
|
|
87
|
+
async def sync(self, base: str, merge_commit: str | None) -> str:
|
|
88
|
+
"""Fetch the remote `base` and return its tip, once it contains `merge_commit`.
|
|
89
|
+
|
|
90
|
+
The fetch updates Shikiru's ref and the remote-tracking ref of `base`; the local branch
|
|
91
|
+
is advanced separately by `LocalBaseSync`. Raises `BaseFetchFailed` when the fetch fails
|
|
92
|
+
and `MergedBaseUnavailable` when the merge commit is missing or not on the base.
|
|
93
|
+
"""
|
|
94
|
+
ref = await self._fetch(base)
|
|
95
|
+
tip = await self._git.rev_parse(self._project_root, ref)
|
|
96
|
+
if merge_commit is None or not await self._git.merge_base_is_ancestor(
|
|
97
|
+
self._project_root, merge_commit, tip
|
|
98
|
+
):
|
|
99
|
+
raise MergedBaseUnavailable(base, merge_commit)
|
|
100
|
+
return tip
|
|
101
|
+
|
|
102
|
+
async def _fetch(self, base: str) -> str:
|
|
103
|
+
ref = f"{BASE_REF_PREFIX}{base}"
|
|
104
|
+
tracking_ref = f"{_REMOTE_TRACKING_PREFIX}{self._remote}/{base}"
|
|
105
|
+
try:
|
|
106
|
+
await self._git.fetch(
|
|
107
|
+
self._project_root,
|
|
108
|
+
self._remote,
|
|
109
|
+
f"+{_HEADS_PREFIX}{base}:{ref}",
|
|
110
|
+
f"+{_HEADS_PREFIX}{base}:{tracking_ref}",
|
|
111
|
+
timeout=self._fetch_timeout,
|
|
112
|
+
)
|
|
113
|
+
except (CommandFailed, CommandTimeoutError) as error:
|
|
114
|
+
raise BaseFetchFailed(base, self._remote, error) from error
|
|
115
|
+
return ref
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class LocalBaseSync:
|
|
119
|
+
"""Moves the local base branch to the verified remote tip, and only fast-forwards it.
|
|
120
|
+
|
|
121
|
+
A checked-out base advances in its own worktree, so git refuses any merge that would
|
|
122
|
+
overwrite uncommitted work. A base that is not checked out moves by compare-and-swap, so a
|
|
123
|
+
concurrent change makes the update fail. A local base holding commits the remote tip lacks
|
|
124
|
+
is never moved.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, git: Git, project_root: Path) -> None:
|
|
128
|
+
"""Advance the branches of the repository at `project_root` through `git`."""
|
|
129
|
+
self._git = git
|
|
130
|
+
self._project_root = project_root
|
|
131
|
+
self._lock = asyncio.Lock()
|
|
132
|
+
|
|
133
|
+
async def advance(self, base: str, tip: str) -> None:
|
|
134
|
+
"""Point the local `base` at `tip`, or raise `LocalBaseBlocked` when that is not safe.
|
|
135
|
+
|
|
136
|
+
The move is verified: a base that does not reach `tip` raises rather than reporting
|
|
137
|
+
success. Idempotent: a base already at `tip` is left alone, so retries and restarts
|
|
138
|
+
are safe.
|
|
139
|
+
"""
|
|
140
|
+
async with self._lock:
|
|
141
|
+
current = await self._git.local_branch_sha(self._project_root, base)
|
|
142
|
+
if current == tip:
|
|
143
|
+
return
|
|
144
|
+
if current is not None and not await self._git.merge_base_is_ancestor(
|
|
145
|
+
self._project_root, current, tip
|
|
146
|
+
):
|
|
147
|
+
raise LocalBaseBlocked(base, tip, _DIVERGED_REASON)
|
|
148
|
+
try:
|
|
149
|
+
await self._move(base, tip, current)
|
|
150
|
+
except (CommandFailed, CommandTimeoutError) as error:
|
|
151
|
+
raise LocalBaseBlocked(base, tip, str(error)) from error
|
|
152
|
+
if await self._git.local_branch_sha(self._project_root, base) != tip:
|
|
153
|
+
raise LocalBaseBlocked(base, tip, _NOT_MOVED_REASON)
|
|
154
|
+
|
|
155
|
+
async def _move(self, base: str, tip: str, current: str | None) -> None:
|
|
156
|
+
checkouts = await self._git.branch_checkouts(self._project_root, base)
|
|
157
|
+
if checkouts:
|
|
158
|
+
await self._git.fast_forward_checked_out(checkouts[0], base, tip)
|
|
159
|
+
return
|
|
160
|
+
await self._git.update_ref(self._project_root, base, tip, current or _MISSING_REF_SHA)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Copy-on-write cache cloning into a task worktree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from shikiru.process import SubprocessRunner
|
|
18
|
+
|
|
19
|
+
_STAGING_SUFFIX = ".cache-staging"
|
|
20
|
+
_PROBE_PREFIX = ".shikiru-cow-probe-"
|
|
21
|
+
_PROBE_DIRECTORY_PREFIX = ".shikiru-cow-check-"
|
|
22
|
+
_PROBE_CONTENT = b"shikiru cow probe"
|
|
23
|
+
|
|
24
|
+
CACHE_COW_UNSUPPORTED_WARNING = (
|
|
25
|
+
"copy-on-write copy is not supported on this filesystem; caches were not cloned"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_MACOS_COW_ARGS = ("cp", "-c", "-R")
|
|
29
|
+
_LINUX_COW_ARGS = ("cp", "-a", "--reflink=always")
|
|
30
|
+
_COW_ARGS_BY_SYSTEM = {"Darwin": _MACOS_COW_ARGS, "Linux": _LINUX_COW_ARGS}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_cow_args() -> tuple[str, ...] | None:
|
|
34
|
+
"""Return this platform's copy-on-write `cp` invocation, or `None` if none is known."""
|
|
35
|
+
return _COW_ARGS_BY_SYSTEM.get(platform.system())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class CacheCloneResult:
|
|
40
|
+
"""The outcome of one `CacheCloner.clone` call."""
|
|
41
|
+
|
|
42
|
+
copied: tuple[Path, ...] = field(default_factory=tuple)
|
|
43
|
+
warning: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _remove_tree(path: Path) -> None:
|
|
47
|
+
if path.exists():
|
|
48
|
+
await asyncio.to_thread(shutil.rmtree, path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CacheCloner:
|
|
52
|
+
"""Clone `repo.caches` globs that are git-ignored, copy-on-write, into a worktree."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self, runner: SubprocessRunner, *, cow_args: Sequence[str] | None, copy_timeout: float
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Copy with `cow_args` (the platform's copy-on-write `cp` invocation), via `runner`.
|
|
58
|
+
|
|
59
|
+
One copy that takes longer than `copy_timeout` seconds fails.
|
|
60
|
+
|
|
61
|
+
`cow_args` is `None` when this platform has no known copy-on-write `cp` invocation
|
|
62
|
+
(see `default_cow_args`); cloning then reports the filesystem as unsupported outright.
|
|
63
|
+
"""
|
|
64
|
+
self._runner = runner
|
|
65
|
+
self._cow_args = tuple(cow_args) if cow_args is not None else None
|
|
66
|
+
self._copy_timeout = copy_timeout
|
|
67
|
+
|
|
68
|
+
async def clone(
|
|
69
|
+
self,
|
|
70
|
+
repo_dir: Path,
|
|
71
|
+
worktree_dir: Path,
|
|
72
|
+
cache_globs: Sequence[str],
|
|
73
|
+
ignored: Sequence[Path],
|
|
74
|
+
) -> CacheCloneResult:
|
|
75
|
+
"""Copy every ignored path in `repo_dir` matching `cache_globs` into `worktree_dir`.
|
|
76
|
+
|
|
77
|
+
Only paths that exist and are in `ignored` are copied. If this filesystem does not
|
|
78
|
+
support copy-on-write, nothing is copied and exactly one warning is returned. Each copy
|
|
79
|
+
is made beside the worktree and moved into place only once complete, so a destination
|
|
80
|
+
that exists is always whole; a failed, timed-out or cancelled copy leaves nothing behind.
|
|
81
|
+
"""
|
|
82
|
+
cow_args = self._cow_args
|
|
83
|
+
if cow_args is None or not await self._cow_supported(cow_args, worktree_dir):
|
|
84
|
+
return CacheCloneResult(warning=CACHE_COW_UNSUPPORTED_WARNING)
|
|
85
|
+
ignored_set = set(ignored)
|
|
86
|
+
copied: list[Path] = []
|
|
87
|
+
staging = worktree_dir.with_name(f"{worktree_dir.name}{_STAGING_SUFFIX}")
|
|
88
|
+
await _remove_tree(staging)
|
|
89
|
+
try:
|
|
90
|
+
for pattern in cache_globs:
|
|
91
|
+
for match in sorted(repo_dir.glob(pattern)):
|
|
92
|
+
if match not in ignored_set or not match.exists():
|
|
93
|
+
continue
|
|
94
|
+
relative = match.relative_to(repo_dir)
|
|
95
|
+
destination = worktree_dir / relative
|
|
96
|
+
if destination.exists():
|
|
97
|
+
continue
|
|
98
|
+
staged = staging / relative
|
|
99
|
+
staged.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
await self._copy(cow_args, match, staged)
|
|
101
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
staged.replace(destination)
|
|
103
|
+
copied.append(relative)
|
|
104
|
+
finally:
|
|
105
|
+
await _remove_tree(staging)
|
|
106
|
+
return CacheCloneResult(copied=tuple(copied))
|
|
107
|
+
|
|
108
|
+
async def check_support(self, filesystem_root: Path) -> bool:
|
|
109
|
+
"""Check copy-on-write using disposable probe files on `filesystem_root`."""
|
|
110
|
+
if self._cow_args is None:
|
|
111
|
+
return False
|
|
112
|
+
with tempfile.TemporaryDirectory(
|
|
113
|
+
prefix=_PROBE_DIRECTORY_PREFIX, dir=filesystem_root
|
|
114
|
+
) as directory:
|
|
115
|
+
return await self._cow_supported(self._cow_args, Path(directory))
|
|
116
|
+
|
|
117
|
+
async def _cow_supported(self, cow_args: tuple[str, ...], worktree_dir: Path) -> bool:
|
|
118
|
+
probe_source = worktree_dir / f"{_PROBE_PREFIX}{uuid4().hex}"
|
|
119
|
+
probe_destination = worktree_dir / f"{_PROBE_PREFIX}{uuid4().hex}-copy"
|
|
120
|
+
probe_source.write_bytes(_PROBE_CONTENT)
|
|
121
|
+
try:
|
|
122
|
+
result = await self._runner.run(
|
|
123
|
+
[*cow_args, str(probe_source), str(probe_destination)],
|
|
124
|
+
timeout=self._copy_timeout,
|
|
125
|
+
)
|
|
126
|
+
finally:
|
|
127
|
+
probe_source.unlink(missing_ok=True)
|
|
128
|
+
probe_destination.unlink(missing_ok=True)
|
|
129
|
+
return result.returncode == 0
|
|
130
|
+
|
|
131
|
+
async def _copy(self, cow_args: tuple[str, ...], source: Path, destination: Path) -> None:
|
|
132
|
+
await self._runner.run(
|
|
133
|
+
[*cow_args, str(source), str(destination)],
|
|
134
|
+
timeout=self._copy_timeout,
|
|
135
|
+
check=True,
|
|
136
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Git facts for `facts.md`, outranking the Implementer's claims."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.domain.briefing import (
|
|
9
|
+
FACTS_COMMITS_HEADING,
|
|
10
|
+
FACTS_DIFF_STAT_HEADING,
|
|
11
|
+
FACTS_NONE_MARKER,
|
|
12
|
+
)
|
|
13
|
+
from shikiru.domain.results import LiveWork
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from shikiru.domain.tasks import RepoState
|
|
20
|
+
from shikiru.workspace.git import Git
|
|
21
|
+
|
|
22
|
+
_HEAD_REF = "HEAD"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class WorkspaceFacts:
|
|
27
|
+
"""The facts computed for a task's worktree."""
|
|
28
|
+
|
|
29
|
+
head_branch: str | None = None
|
|
30
|
+
commits: tuple[str, ...] = field(default_factory=tuple)
|
|
31
|
+
diff_stat: str = ""
|
|
32
|
+
status: tuple[str, ...] = field(default_factory=tuple)
|
|
33
|
+
changed_trackers: tuple[str, ...] = field(default_factory=tuple)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
DETACHED_HEAD = "a detached HEAD"
|
|
37
|
+
OFF_BRANCH_ISSUE = (
|
|
38
|
+
"The workspace is on {actual}, not its task branch {branch}, so its commits "
|
|
39
|
+
"({commits}) would never be integrated. Bring that work onto {branch}, switch back to it "
|
|
40
|
+
"and stay on it."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def describe_off_branch(branch: str, head_branch: str | None, commits: Sequence[str]) -> str:
|
|
45
|
+
"""Describe a worktree whose `HEAD` is not on `branch`, naming the commits at risk."""
|
|
46
|
+
return OFF_BRANCH_ISSUE.format(
|
|
47
|
+
actual=head_branch or DETACHED_HEAD,
|
|
48
|
+
branch=branch,
|
|
49
|
+
commits="; ".join(commits) or "none ahead of the base",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_facts(facts: WorkspaceFacts) -> str:
|
|
54
|
+
"""Render `facts` as the markdown text a `verify`/`conflicts`/`handoff` prompt embeds.
|
|
55
|
+
|
|
56
|
+
These facts outrank the Implementer's claims, so they carry no interpretation, only the
|
|
57
|
+
raw `git` output `GitFacts.collect` gathered.
|
|
58
|
+
"""
|
|
59
|
+
lines = [FACTS_COMMITS_HEADING]
|
|
60
|
+
lines.append("\n".join(facts.commits) if facts.commits else FACTS_NONE_MARKER)
|
|
61
|
+
lines.append(FACTS_DIFF_STAT_HEADING)
|
|
62
|
+
lines.append(facts.diff_stat or FACTS_NONE_MARKER)
|
|
63
|
+
lines.append("### git status --porcelain")
|
|
64
|
+
lines.append("\n".join(facts.status) if facts.status else "(clean)")
|
|
65
|
+
lines.append("### Changed trackers")
|
|
66
|
+
lines.append(", ".join(facts.changed_trackers) if facts.changed_trackers else "(none)")
|
|
67
|
+
return "\n\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GitFacts:
|
|
71
|
+
"""Computes `WorkspaceFacts` for a task's worktree, from real git state."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, git: Git) -> None:
|
|
74
|
+
"""Read git state through `git`."""
|
|
75
|
+
self._git = git
|
|
76
|
+
|
|
77
|
+
async def collect(
|
|
78
|
+
self, task_root: Path, repo: RepoState, trackers: Sequence[str]
|
|
79
|
+
) -> WorkspaceFacts:
|
|
80
|
+
"""Collect facts for the worktree at `task_root`, checked out as `repo`."""
|
|
81
|
+
head_branch = await self._git.head_branch(task_root)
|
|
82
|
+
diff_range = f"{repo.base_ref}...HEAD"
|
|
83
|
+
commits = await self._git.log_oneline(task_root, f"{repo.base_ref}..HEAD")
|
|
84
|
+
diff_stat = await self._git.diff_stat(task_root, diff_range)
|
|
85
|
+
status = await self._git.status_porcelain(task_root)
|
|
86
|
+
changed_paths = set(await self._git.diff_name_only(task_root, diff_range))
|
|
87
|
+
return WorkspaceFacts(
|
|
88
|
+
head_branch=head_branch,
|
|
89
|
+
commits=tuple(commits),
|
|
90
|
+
diff_stat=diff_stat,
|
|
91
|
+
status=tuple(status),
|
|
92
|
+
changed_trackers=tuple(tracker for tracker in trackers if tracker in changed_paths),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TaskWorkObserver:
|
|
97
|
+
"""Reads a task's live work from its worktree, as it is now rather than as recorded."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, git: Git) -> None:
|
|
100
|
+
"""Read git state through `git`."""
|
|
101
|
+
self._git = git
|
|
102
|
+
|
|
103
|
+
async def observe(self, task_root: Path, base_ref: str) -> LiveWork:
|
|
104
|
+
"""Return the commits past `base_ref` and the changes of `task_root` against their base.
|
|
105
|
+
|
|
106
|
+
Tracked edits, staged or not, are counted once against the merge base of `base_ref` and
|
|
107
|
+
`HEAD`, so committed work is never counted twice. Untracked and binary files count as
|
|
108
|
+
files, but contribute no line counts, because git gives none for them.
|
|
109
|
+
"""
|
|
110
|
+
merge_base = await self._git.merge_base(task_root, base_ref, _HEAD_REF)
|
|
111
|
+
commits = await self._git.log_oneline(task_root, f"{base_ref}..{_HEAD_REF}")
|
|
112
|
+
changes = await self._git.working_tree_line_changes(task_root, against=merge_base)
|
|
113
|
+
return LiveWork.from_oneline(
|
|
114
|
+
commits,
|
|
115
|
+
files=changes.changed_paths,
|
|
116
|
+
insertions=changes.added_lines,
|
|
117
|
+
deletions=changes.deleted_lines,
|
|
118
|
+
)
|