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,107 @@
|
|
|
1
|
+
"""Queues operator messages per role and delivers them into the next turn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from itertools import chain
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.paths import MESSAGES_DIR_NAME, queued_messages_dir, run_dir, turns_dir
|
|
9
|
+
from shikiru.private import make_private_dir, write_private_text
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from shikiru.domain.tasks import Role
|
|
16
|
+
|
|
17
|
+
MESSAGE_NUMBER_WIDTH = 3
|
|
18
|
+
MESSAGE_SUFFIX = ".md"
|
|
19
|
+
MESSAGE_KEY_SEPARATOR = "-"
|
|
20
|
+
PARTIAL_SUFFIX = ".partial"
|
|
21
|
+
COMMAND_KEY_PREFIX = "c"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OperatorMessages:
|
|
25
|
+
"""Files the Operator's messages under `runs/T/messages/<role>/NNN.md`."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, project_root: Path) -> None:
|
|
28
|
+
"""Queue messages under `project_root`'s run directories."""
|
|
29
|
+
self._project_root = project_root
|
|
30
|
+
|
|
31
|
+
def queue(self, task_id: str, role: Role, text: str, key: str) -> Path:
|
|
32
|
+
"""Write `text` as the role's next numbered message and return its path.
|
|
33
|
+
|
|
34
|
+
The file name carries `key`; a message already filed under `key`, queued or delivered,
|
|
35
|
+
is returned instead of being written again, so a replayed request queues it once.
|
|
36
|
+
"""
|
|
37
|
+
existing = self._find(task_id, key)
|
|
38
|
+
if existing is not None:
|
|
39
|
+
return existing
|
|
40
|
+
directory = queued_messages_dir(self._project_root, task_id, role.value)
|
|
41
|
+
make_private_dir(directory)
|
|
42
|
+
number = self._last_number(task_id) + 1
|
|
43
|
+
path = _message_path(directory, number, key)
|
|
44
|
+
_write_atomically(path, text)
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
def deliver(self, task_id: str, role: Role, turn_dir: Path) -> list[Path]:
|
|
48
|
+
"""Move the role's queued messages into `turn_dir/messages/` and return them in order."""
|
|
49
|
+
directory = queued_messages_dir(self._project_root, task_id, role.value)
|
|
50
|
+
queued = _messages(directory) if directory.is_dir() else []
|
|
51
|
+
if not queued:
|
|
52
|
+
return []
|
|
53
|
+
target = turn_dir / MESSAGES_DIR_NAME
|
|
54
|
+
make_private_dir(target)
|
|
55
|
+
return [path.rename(target / path.name) for path in queued]
|
|
56
|
+
|
|
57
|
+
def requeue(self, task_id: str, role: Role, turn_dir: Path) -> None:
|
|
58
|
+
"""Return `turn_dir`'s delivered messages to the role's queue, ahead of newer ones.
|
|
59
|
+
|
|
60
|
+
Each message moves back under its own name, and numbers only grow, so the queue order is
|
|
61
|
+
the filing order; one that has not moved yet when the process dies is returned by the
|
|
62
|
+
next call, never lost.
|
|
63
|
+
"""
|
|
64
|
+
delivered_dir = turn_dir / MESSAGES_DIR_NAME
|
|
65
|
+
delivered = _messages(delivered_dir) if delivered_dir.is_dir() else []
|
|
66
|
+
if not delivered:
|
|
67
|
+
return
|
|
68
|
+
directory = queued_messages_dir(self._project_root, task_id, role.value)
|
|
69
|
+
make_private_dir(directory)
|
|
70
|
+
for path in delivered:
|
|
71
|
+
path.rename(directory / path.name)
|
|
72
|
+
delivered_dir.rmdir()
|
|
73
|
+
|
|
74
|
+
def _filed(self, task_id: str, pattern: str = f"*{MESSAGE_SUFFIX}") -> Iterator[Path]:
|
|
75
|
+
queued = run_dir(self._project_root, task_id).glob(f"{MESSAGES_DIR_NAME}/*/{pattern}")
|
|
76
|
+
delivered = turns_dir(self._project_root, task_id).glob(f"*/{MESSAGES_DIR_NAME}/{pattern}")
|
|
77
|
+
return chain(queued, delivered)
|
|
78
|
+
|
|
79
|
+
def _last_number(self, task_id: str) -> int:
|
|
80
|
+
return max((_number(path) for path in self._filed(task_id)), default=0)
|
|
81
|
+
|
|
82
|
+
def _find(self, task_id: str, key: str) -> Path | None:
|
|
83
|
+
return next(self._filed(task_id, f"*{MESSAGE_KEY_SEPARATOR}{key}{MESSAGE_SUFFIX}"), None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def command_message_key(command_id: int) -> str:
|
|
87
|
+
"""Return the message key that ties a filed message to the command that queued it."""
|
|
88
|
+
return f"{COMMAND_KEY_PREFIX}{command_id}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _number(path: Path) -> int:
|
|
92
|
+
return int(path.stem.partition(MESSAGE_KEY_SEPARATOR)[0])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_atomically(path: Path, text: str) -> None:
|
|
96
|
+
partial = path.with_name(path.name + PARTIAL_SUFFIX)
|
|
97
|
+
write_private_text(partial, text)
|
|
98
|
+
partial.replace(path)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _messages(directory: Path) -> list[Path]:
|
|
102
|
+
return sorted(directory.glob(f"*{MESSAGE_SUFFIX}"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _message_path(directory: Path, number: int, key: str) -> Path:
|
|
106
|
+
name = f"{number:0{MESSAGE_NUMBER_WIDTH}d}{MESSAGE_KEY_SEPARATOR}{key}{MESSAGE_SUFFIX}"
|
|
107
|
+
return directory / name
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Read validated reports from one completed turn directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from shikiru.domain.reports import (
|
|
12
|
+
COMMIT_SUBJECT_FIELD,
|
|
13
|
+
DECISION_ACCEPT,
|
|
14
|
+
HELPER_REPORT_FILENAME,
|
|
15
|
+
REPORT_DECISION_FIELD,
|
|
16
|
+
REPORT_FILENAME,
|
|
17
|
+
REPORT_KIND_FILENAME,
|
|
18
|
+
FeedbackVerdict,
|
|
19
|
+
PlanReport,
|
|
20
|
+
Report,
|
|
21
|
+
ReportKind,
|
|
22
|
+
Verdict,
|
|
23
|
+
model_for,
|
|
24
|
+
)
|
|
25
|
+
from shikiru.errors import ShikiruError
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
_REPORT_FILENAMES = (REPORT_FILENAME, HELPER_REPORT_FILENAME)
|
|
31
|
+
ENGINE_VERDICT_FILENAME = "engine-verdict.json"
|
|
32
|
+
LEGACY_COMMIT_SUBJECT = "chore: legacy report"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidTurnReportError(ShikiruError):
|
|
36
|
+
"""A turn report file exists but its kind, JSON or schema is invalid."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class LoadedReport:
|
|
41
|
+
"""A validated report and the kind declared by its turn directory."""
|
|
42
|
+
|
|
43
|
+
kind: ReportKind
|
|
44
|
+
report: Report
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_turn_report(turn_dir: Path) -> LoadedReport | None:
|
|
48
|
+
"""Load a valid report, return `None` only when no report files exist."""
|
|
49
|
+
kind_path = turn_dir / REPORT_KIND_FILENAME
|
|
50
|
+
kind = _read_kind(kind_path)
|
|
51
|
+
if kind is None:
|
|
52
|
+
if any(_file_exists(turn_dir / filename) for filename in _REPORT_FILENAMES):
|
|
53
|
+
message = f"report kind is missing: {kind_path}"
|
|
54
|
+
raise InvalidTurnReportError(message)
|
|
55
|
+
return None
|
|
56
|
+
model = model_for(kind)
|
|
57
|
+
invalid: InvalidTurnReportError | None = None
|
|
58
|
+
for filename in _REPORT_FILENAMES:
|
|
59
|
+
try:
|
|
60
|
+
report = _read_report(turn_dir / filename, model)
|
|
61
|
+
except InvalidTurnReportError as error:
|
|
62
|
+
if invalid is None:
|
|
63
|
+
invalid = error
|
|
64
|
+
continue
|
|
65
|
+
if report is not None:
|
|
66
|
+
return LoadedReport(kind=kind, report=report)
|
|
67
|
+
if invalid is not None:
|
|
68
|
+
raise invalid
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_engine_verdict(turn_dir: Path) -> Verdict | None:
|
|
73
|
+
"""Load the engine-computed verdict stored beside a turn's agent report."""
|
|
74
|
+
path = turn_dir / ENGINE_VERDICT_FILENAME
|
|
75
|
+
try:
|
|
76
|
+
text = path.read_text()
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
return None
|
|
79
|
+
except (OSError, UnicodeError) as error:
|
|
80
|
+
message = f"cannot read engine verdict {path}: {error}"
|
|
81
|
+
raise InvalidTurnReportError(message) from error
|
|
82
|
+
model = (
|
|
83
|
+
FeedbackVerdict
|
|
84
|
+
if _read_kind(turn_dir / REPORT_KIND_FILENAME) is ReportKind.FEEDBACK_VERDICT
|
|
85
|
+
else Verdict
|
|
86
|
+
)
|
|
87
|
+
data = json.loads(text)
|
|
88
|
+
return cast("Verdict", _validate_stored_report(data, model))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _file_exists(path: Path) -> bool:
|
|
92
|
+
try:
|
|
93
|
+
path.stat()
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
return False
|
|
96
|
+
except OSError as error:
|
|
97
|
+
message = f"cannot inspect report file {path}: {error}"
|
|
98
|
+
raise InvalidTurnReportError(message) from error
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_kind(path: Path) -> ReportKind | None:
|
|
103
|
+
try:
|
|
104
|
+
value = path.read_text().strip()
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
return None
|
|
107
|
+
except (OSError, UnicodeError) as error:
|
|
108
|
+
message = f"cannot read report kind {path}: {error}"
|
|
109
|
+
raise InvalidTurnReportError(message) from error
|
|
110
|
+
try:
|
|
111
|
+
return ReportKind(value)
|
|
112
|
+
except ValueError as error:
|
|
113
|
+
message = f"invalid report kind in {path}: {value!r}"
|
|
114
|
+
raise InvalidTurnReportError(message) from error
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_report(path: Path, model: type[Report]) -> Report | None:
|
|
118
|
+
try:
|
|
119
|
+
text = path.read_text()
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
return None
|
|
122
|
+
except (OSError, UnicodeError) as error:
|
|
123
|
+
message = f"cannot read report {path}: {error}"
|
|
124
|
+
raise InvalidTurnReportError(message) from error
|
|
125
|
+
try:
|
|
126
|
+
data = json.loads(text)
|
|
127
|
+
except json.JSONDecodeError as error:
|
|
128
|
+
message = f"invalid JSON in report {path}: {error}"
|
|
129
|
+
raise InvalidTurnReportError(message) from error
|
|
130
|
+
try:
|
|
131
|
+
return _validate_stored_report(data, model)
|
|
132
|
+
except ValidationError as error:
|
|
133
|
+
message = f"invalid report {path}: {error}"
|
|
134
|
+
raise InvalidTurnReportError(message) from error
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate_stored_report(data: object, model: type[Report]) -> Report:
|
|
138
|
+
if not isinstance(data, dict) or COMMIT_SUBJECT_FIELD in data:
|
|
139
|
+
return model.model_validate(data)
|
|
140
|
+
fields = cast("dict[str, object]", data)
|
|
141
|
+
if model is PlanReport or (
|
|
142
|
+
model in (Verdict, FeedbackVerdict) and fields.get(REPORT_DECISION_FIELD) == DECISION_ACCEPT
|
|
143
|
+
):
|
|
144
|
+
return model.model_validate({**fields, COMMIT_SUBJECT_FIELD: LEGACY_COMMIT_SUBJECT})
|
|
145
|
+
return model.model_validate(data)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Task roots: one worktree of the project repository per task, and cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.tasks import RepoState
|
|
11
|
+
from shikiru.errors import StaleTaskBranchError
|
|
12
|
+
from shikiru.paths import EXCLUDE_LINE, run_dir, task_root
|
|
13
|
+
from shikiru.private import make_private_dir
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from shikiru.config.settings import RepoConfig
|
|
20
|
+
from shikiru.workspace.base_refs import BaseRefs
|
|
21
|
+
from shikiru.workspace.caches import CacheCloner
|
|
22
|
+
from shikiru.workspace.git import Git
|
|
23
|
+
|
|
24
|
+
BRANCH_PREFIX = "shikiru/"
|
|
25
|
+
SEED_COMMIT_MESSAGE = "Carry over the tracker changes of the superseded task"
|
|
26
|
+
__all__ = ["EXCLUDE_LINE", "TaskRootBuilder", "branch_name", "run_dir", "slugify", "task_root"]
|
|
27
|
+
|
|
28
|
+
_SLUG_MAX_LENGTH = 40
|
|
29
|
+
_SHORT_SHA = 10
|
|
30
|
+
_NON_ALPHANUMERIC = re.compile(r"[^a-z0-9]+")
|
|
31
|
+
GIT_LINK_NAME = ".git"
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def slugify(title: str) -> str:
|
|
36
|
+
"""Turn `title` into a stable, filesystem-safe branch slug.
|
|
37
|
+
|
|
38
|
+
Lowercases, replaces every run of non-alphanumeric characters with a single `-`, trims
|
|
39
|
+
leading and trailing `-`, and caps the result at `_SLUG_MAX_LENGTH` characters.
|
|
40
|
+
"""
|
|
41
|
+
collapsed = _NON_ALPHANUMERIC.sub("-", title.lower()).strip("-")
|
|
42
|
+
return collapsed[:_SLUG_MAX_LENGTH].strip("-")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def branch_name(task_id: str, title: str) -> str:
|
|
46
|
+
"""Build the deterministic task branch name `shikiru/T-n-<slug>`."""
|
|
47
|
+
return f"{BRANCH_PREFIX}{task_id}-{slugify(title)}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TaskRootBuilder:
|
|
51
|
+
"""Builds and tears down `.shikiru/worktrees/T-n/` for a task."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
git: Git,
|
|
56
|
+
project_root: Path,
|
|
57
|
+
repo: RepoConfig,
|
|
58
|
+
cache_cloner: CacheCloner,
|
|
59
|
+
base_refs: BaseRefs,
|
|
60
|
+
*,
|
|
61
|
+
trackers: Sequence[str],
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Build task roots starting from the ref `base_refs` picks, cloning configured caches."""
|
|
64
|
+
self._git = git
|
|
65
|
+
self._project_root = project_root
|
|
66
|
+
self._repo = repo
|
|
67
|
+
self._trackers = trackers
|
|
68
|
+
self._cache_cloner = cache_cloner
|
|
69
|
+
self._base_refs = base_refs
|
|
70
|
+
self._warned_cow_unsupported = False
|
|
71
|
+
|
|
72
|
+
async def build(self, task_id: str, title: str, *, seed_branch: str | None = None) -> RepoState:
|
|
73
|
+
"""Build (or reuse) `task_id`'s task root: one worktree of the project repository.
|
|
74
|
+
|
|
75
|
+
A new root of a replacement task gets, as its first commit, the tracker changes of
|
|
76
|
+
`seed_branch` (the superseded task's branch) and nothing else of it.
|
|
77
|
+
|
|
78
|
+
Idempotent: calling this again with the same arguments creates nothing twice, whether
|
|
79
|
+
the branch, the worktree or both already exist.
|
|
80
|
+
"""
|
|
81
|
+
root = task_root(self._project_root, task_id)
|
|
82
|
+
make_private_dir(root.parent)
|
|
83
|
+
branch = branch_name(task_id, title)
|
|
84
|
+
project = self._project_root
|
|
85
|
+
repo = self._repo
|
|
86
|
+
base = repo.base if repo.base is not None else await self._git.current_branch(project)
|
|
87
|
+
base_ref = await self._base_refs.resolve(base)
|
|
88
|
+
base_sha = await self._git.rev_parse(project, base_ref)
|
|
89
|
+
await self._git.append_to_exclude(project, EXCLUDE_LINE)
|
|
90
|
+
if not await self._git.worktree_exists(project, root):
|
|
91
|
+
if await self._git.branch_exists(project, branch):
|
|
92
|
+
await self._require_fresh_branch(branch, base_sha)
|
|
93
|
+
await self._git.worktree_add_existing_branch(project, root, branch)
|
|
94
|
+
else:
|
|
95
|
+
await self._git.worktree_add(project, root, new_branch=branch, base=base_ref)
|
|
96
|
+
if seed_branch is not None:
|
|
97
|
+
await self._seed_trackers(root, branch, base_ref, seed_branch)
|
|
98
|
+
if repo.caches:
|
|
99
|
+
clone_result = await self._cache_cloner.clone(
|
|
100
|
+
project, root, repo.caches, await self._git.ignored_paths(project)
|
|
101
|
+
)
|
|
102
|
+
if clone_result.warning is not None and not self._warned_cow_unsupported:
|
|
103
|
+
_LOGGER.warning("%s", clone_result.warning)
|
|
104
|
+
self._warned_cow_unsupported = True
|
|
105
|
+
return RepoState(base=base, base_ref=base_ref, base_sha=base_sha, branch=branch)
|
|
106
|
+
|
|
107
|
+
async def _require_fresh_branch(self, branch: str, base_sha: str) -> None:
|
|
108
|
+
if await self._git.rev_parse(self._project_root, branch) != base_sha:
|
|
109
|
+
message = (
|
|
110
|
+
f"branch {branch} already exists and is not at the base {base_sha[:_SHORT_SHA]}: "
|
|
111
|
+
f"it holds an earlier task's work; inspect it, then delete it "
|
|
112
|
+
f"(git branch -D {branch}) or rename it and submit the task again"
|
|
113
|
+
)
|
|
114
|
+
raise StaleTaskBranchError(message)
|
|
115
|
+
|
|
116
|
+
async def _seed_trackers(
|
|
117
|
+
self, root: Path, branch: str, base_ref: str, seed_branch: str
|
|
118
|
+
) -> None:
|
|
119
|
+
project = self._project_root
|
|
120
|
+
if not self._trackers or not await self._git.branch_exists(project, seed_branch):
|
|
121
|
+
return
|
|
122
|
+
try:
|
|
123
|
+
patch = await self._git.diff_patch(
|
|
124
|
+
project, f"{base_ref}...{seed_branch}", self._trackers
|
|
125
|
+
)
|
|
126
|
+
if patch:
|
|
127
|
+
await self._git.apply_patch(root, patch)
|
|
128
|
+
await self._git.commit(root, SEED_COMMIT_MESSAGE)
|
|
129
|
+
except Exception:
|
|
130
|
+
await self._git.worktree_remove(project, root)
|
|
131
|
+
await self._git.delete_branch(project, branch)
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
async def _holds_unintegrated_work(self, root: Path, repo: RepoState) -> bool:
|
|
135
|
+
if await self._git.worktree_removal_pending(self._project_root, root):
|
|
136
|
+
return False
|
|
137
|
+
if await self._git.head_branch(root) != repo.branch:
|
|
138
|
+
return True
|
|
139
|
+
return bool(await self._git.status_porcelain(root))
|
|
140
|
+
|
|
141
|
+
async def cleanup(
|
|
142
|
+
self,
|
|
143
|
+
task_id: str,
|
|
144
|
+
repo: RepoState | None,
|
|
145
|
+
*,
|
|
146
|
+
integrated: bool = False,
|
|
147
|
+
keep_work: bool = False,
|
|
148
|
+
) -> Path | None:
|
|
149
|
+
"""Remove the task's worktree, its merged branch, and last its task root.
|
|
150
|
+
|
|
151
|
+
The branch is merged when it is an ancestor of the local base or when the caller says
|
|
152
|
+
the task is `integrated` (a pull request merged on the remote leaves the local base
|
|
153
|
+
behind, and a squash leaves the branch out of its history). Idempotent and tolerant of
|
|
154
|
+
a worktree already removed. Never deletes an unmerged branch, never follows a symlink
|
|
155
|
+
out of the task root, and never erases unintegrated work: a worktree whose `HEAD` is off
|
|
156
|
+
the task branch, one with tracked or untracked changes (git-ignored files excepted) and
|
|
157
|
+
its branch stay, and so does a root that holds anything but the git link once its
|
|
158
|
+
worktree metadata is gone (git cannot tell what is uncommitted there); the kept task
|
|
159
|
+
root is returned. With `keep_work` the caller knows the branch holds commits that are
|
|
160
|
+
nowhere else: its worktree and branch stay. A removal that began (it passed those checks)
|
|
161
|
+
and was interrupted by a failure or timeout leaves a half-deleted worktree that the next
|
|
162
|
+
cleanup removes without taking the deletions for uncommitted work.
|
|
163
|
+
"""
|
|
164
|
+
project = self._project_root
|
|
165
|
+
root = task_root(project, task_id)
|
|
166
|
+
if repo is not None:
|
|
167
|
+
if await self._git.worktree_exists(project, root):
|
|
168
|
+
if keep_work or await self._holds_unintegrated_work(root, repo):
|
|
169
|
+
return root
|
|
170
|
+
await self._git.worktree_remove(project, root)
|
|
171
|
+
if await self._git.branch_exists(project, repo.branch) and (
|
|
172
|
+
integrated
|
|
173
|
+
or await self._git.merge_base_is_ancestor(project, repo.branch, repo.base)
|
|
174
|
+
):
|
|
175
|
+
await self._git.delete_branch(project, repo.branch)
|
|
176
|
+
if root.exists():
|
|
177
|
+
if repo is not None and any(entry.name != GIT_LINK_NAME for entry in root.iterdir()):
|
|
178
|
+
return root
|
|
179
|
+
shutil.rmtree(root)
|
|
180
|
+
return None
|